diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index bb5b67d..cf3ba20 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -11,7 +11,7 @@ on: ref: description: 'Branch o commit da buildare' required: false - default: 'claude/fix-shift-permissions-Cn8gk' + default: 'main' version_override: description: 'Tag release (es. v2.6.2). Vuoto = legge app.json' required: false @@ -28,6 +28,11 @@ env: jobs: build: runs-on: ubuntu-latest + env: + RELEASE_STORE_FILE: ${{ github.workspace }}/android/app/release.keystore + RELEASE_STORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }} + RELEASE_KEY_ALIAS: ${{ secrets.KEY_ALIAS }} + RELEASE_KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }} steps: - name: Compute checkout ref @@ -38,7 +43,7 @@ jobs: elif [[ "${{ github.ref }}" == refs/tags/* ]]; then echo "ref=${{ github.ref_name }}" >> "$GITHUB_OUTPUT" else - echo "ref=claude/fix-shift-permissions-Cn8gk" >> "$GITHUB_OUTPUT" + echo "ref=main" >> "$GITHUB_OUTPUT" fi - name: Checkout @@ -74,8 +79,8 @@ jobs: run: | echo "y" | sdkmanager \ "ndk;27.1.12297006" \ - "build-tools;36.0.0" \ - "platforms;android-36" \ + "build-tools;37.0.0" \ + "platforms;android-37.0" \ "cmake;3.22.1" - name: Install dependencies @@ -104,10 +109,20 @@ jobs: - name: Make gradlew executable run: chmod +x android/gradlew - - name: Restore stable keystore + - name: Restore release keystore run: | + if [[ -z "${{ secrets.KEYSTORE_BASE64 }}" ]]; then + echo "Missing KEYSTORE_BASE64 secret" + exit 1 + fi + for var in RELEASE_STORE_PASSWORD RELEASE_KEY_ALIAS RELEASE_KEY_PASSWORD; do + if [[ -z "${!var}" ]]; then + echo "Missing $var secret" + exit 1 + fi + done mkdir -p android/app - echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > android/app/debug.keystore + echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 -d > "$RELEASE_STORE_FILE" - name: Build release APK run: | @@ -131,34 +146,7 @@ jobs: with: tag_name: ${{ steps.meta.outputs.tag }} name: "AeroStaff Pro ${{ steps.meta.outputs.tag }}" - body: | - ## πŸ“¦ AeroStaff Pro ${{ steps.meta.outputs.tag }} - - ### NovitΓ  - - **Backup / Ripristino**: esporta tutti i dati dell'app in un file JSON e reimportali in qualsiasi momento da Impostazioni - - **Aggiornamento in-app**: popup stile Mihon con changelog e download APK diretto; controllo automatico all'avvio ogni 24h - - **Impostazioni riprogettate**: sezione aggiornamenti con card dedicata e badge NEW; backup con due tile colorate (esporta verde, importa blu) - - ### Bug fix – Turni e Home - - **HomeScreen "Giorno di riposo" errato**: l'evento Lavoro nel calendario ora ha sempre la precedenza su un eventuale evento Riposo dello stesso giorno - - **Widget "Giorno di riposo" errato**: WIDGET_SHIFT_KEY Γ¨ ora la fonte autoritativa per classificare lavoro/riposo, impedendo che dati in cache stantii sovrascrivano il turno reale - - **FlightScreen**: trovato evento Lavoro nel calendario non azzerava `isRestDay` se preceduto da un evento Riposo; corretto - - ### Bug fix – Voli - - **Aggiornamento automatico voli**: i dati FR24 ora si ricaricano ogni 2 minuti senza riaprire l'app - - **Duplicati voli**: chiave di merge stabilizzata su `numeroVolo_tsPartenza` (prima usava `identification.id` che FR24 a volte omette) - - ### Bug fix – StaffMonitor stand / gate / arrivi - - **Colonne non rilevate**: rimosso il requisito di trovare stand/gate/belt prima di parsare; prima saltava tutte le righe - - **Nomi handler nella colonna stand**: `cell()` ora estrae solo il primo token alfanumerico (es. `17β—† Federico` β†’ `17`) - - **Numeri di telefono come stand**: `isPhoneOrJunk` individua sequenze di 8+ cifre ovunque nella stringa - - **Colonna "ADDETTO STAND"**: word-boundary `\bstand\b` per evitare falsi positivi - - **Arrivi in parallelo + cache**: 7 varianti URL `nature=A` in simultanea, prima risposta vince in ≀40s; cache AsyncStorage 20 min - - **Sessione Tomcat (JSESSIONID)**: cookie catturato dalla risposta D e inoltrato alle richieste A β€” fix AbortError sugli arrivi - - **Numeri volo corrotti**: rimossi URL fallback senza `nature=A` - - **Timeout partenze**: ripristinato a 25s - - Scarica `AeroStaffPro-${{ steps.meta.outputs.tag }}.apk` e installalo sul tuo dispositivo Android. + generate_release_notes: true files: ${{ env.apk }} draft: false prerelease: false diff --git a/App.tsx b/App.tsx index bd5d2a3..42f908d 100644 --- a/App.tsx +++ b/App.tsx @@ -17,9 +17,18 @@ import PhonebookScreen from './src/screens/PhonebookScreen'; import SettingsScreen from './src/screens/SettingsScreen'; import PasswordScreen from './src/screens/PasswordScreen'; import DrawerMenu from './src/components/DrawerMenu'; +import ProfileSwitcherModal from './src/components/ProfileSwitcherModal'; +import FrostedSurface from './src/components/FrostedSurface'; +import { + installGlobalCrashHandler, + markRuntimeStartupCompleted, +} from './src/utils/runtimeDiagnostics'; import { autoScheduleNotifications } from './src/utils/autoNotifications'; import { checkForUpdate, wasUpdateSeen, markUpdateSeen, type UpdateInfo } from './src/utils/updateChecker'; import UpdateModal from './src/components/UpdateModal'; +import { useAirport } from './src/context/AirportContext'; + +installGlobalCrashHandler(); type Tab = 'Shifts' | 'Calendar' | 'Flights' | 'TravelDoc'; type OverlayScreen = 'Notepad' | 'Phonebook' | 'Passwords' | 'Manuals' | 'Settings' | null; @@ -50,13 +59,13 @@ function GlassTab({ icon, label, focused, activeColor, inactiveColor, onPress }: }) { const scale = useRef(new Animated.Value(focused ? 1.15 : 1)).current; const translateY = useRef(new Animated.Value(focused ? -4 : 0)).current; - const opacity = useRef(new Animated.Value(focused ? 1 : 0.6)).current; + const opacity = useRef(new Animated.Value(focused ? 1 : 0.78)).current; useEffect(() => { Animated.parallel([ Animated.spring(scale, { toValue: focused ? 1.15 : 1, useNativeDriver: true, tension: 200, friction: 15 }), Animated.spring(translateY, { toValue: focused ? -4 : 0, useNativeDriver: true, tension: 200, friction: 15 }), - Animated.timing(opacity, { toValue: focused ? 1 : 0.5, duration: 150, useNativeDriver: true }), + Animated.timing(opacity, { toValue: focused ? 1 : 0.74, duration: 150, useNativeDriver: true }), ]).start(); }, [focused]); @@ -81,10 +90,13 @@ function GlassTab({ icon, label, focused, activeColor, inactiveColor, onPress }: function AppInner() { const { colors, mode } = useAppTheme(); const { t } = useLanguage(); + const { profileInitials } = useAirport(); const [activeTab, setActiveTab] = useState('Shifts'); const [drawerOpen, setDrawerOpen] = useState(false); const [overlay, setOverlay] = useState(null); + const [openFlightNotifSettingsSignal, setOpenFlightNotifSettingsSignal] = useState(0); const [pendingUpdate, setPendingUpdate] = useState(null); + const [profileModalOpen, setProfileModalOpen] = useState(false); const tabLabels: Record = { Shifts: t('tabHome'), Calendar: t('tabShifts'), Flights: t('tabFlights'), TravelDoc: t('tabTravelDoc'), @@ -99,6 +111,8 @@ function AppInner() { // ─── Auto-schedule flight notifications on startup ───────────────────────── useEffect(() => { + markRuntimeStartupCompleted().catch(() => {}); + autoScheduleNotifications().then(count => { if (count > 0 && __DEV__) console.log(`Auto-scheduled ${count} notifications`); }).catch(() => {}); @@ -108,6 +122,7 @@ function AppInner() { const seen = await wasUpdateSeen(info.latestVersion); if (!seen) setPendingUpdate(info); }).catch(() => {}); + }, []); // ─── Android back button: overlay β†’ home, drawer β†’ close ─────────────────── @@ -133,6 +148,13 @@ function AppInner() { setActiveTab(TABS[newIdx].id); }; + const openFlightNotificationsFromSettings = () => { + setDrawerOpen(false); + setOverlay(null); + goToTab(2); + setOpenFlightNotifSettingsSignal(prev => prev + 1); + }; + const swipePan = useMemo(() => PanResponder.create({ onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dx) > 30 && Math.abs(g.dx) > Math.abs(g.dy) * 2, @@ -170,7 +192,7 @@ function AppInner() { if (overlay === 'Phonebook') return ; if (overlay === 'Passwords') return ; if (overlay === 'Manuals') return ; - if (overlay === 'Settings') return ; + if (overlay === 'Settings') return ; return null; }; @@ -178,7 +200,7 @@ function AppInner() { switch (tab) { case 'Shifts': return ; case 'Calendar': return ; - case 'Flights': return ; + case 'Flights': return ; case 'TravelDoc': return ; } }; @@ -186,6 +208,7 @@ function AppInner() { const appBarTitle = overlay ? overlayTitles[overlay] : 'AeroStaff Pro'; const isWeather = mode === 'weather' && !!colors.gradient; + const tabInactiveColor = colors.isDark ? 'rgba(235,239,245,0.78)' : colors.tabIconInactive; return ( @@ -194,7 +217,7 @@ function AppInner() { backgroundColor={colors.appBar} /> - {/* Top App Bar β€” liquid glass */} + {/* Top App Bar */} {colors.weatherIcon} {colors.weatherLabel} )} - - MR - + setProfileModalOpen(true)} activeOpacity={0.85}> + + {profileInitials} + + {/* Screen Content */} @@ -257,12 +282,16 @@ function AppInner() { {/* Bottom Nav β€” Glassmorphic Floating Pill (hidden on overlay screens) */} {!overlay && ( - - + {TABS.map(tab => { const active = activeTab === tab.id; @@ -273,7 +302,7 @@ function AppInner() { label={tabLabels[tab.id]} focused={active} activeColor={colors.tabIconActive} - inactiveColor={colors.tabIconInactive} + inactiveColor={tabInactiveColor} onPress={() => { Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); goToTab(TABS.findIndex(t => t.id === tab.id)); @@ -282,7 +311,7 @@ function AppInner() { ); })} - + )} @@ -292,6 +321,10 @@ function AppInner() { onClose={() => setDrawerOpen(false)} onSelect={handleDrawerSelect} /> + setProfileModalOpen(false)} + /> {pendingUpdate && ( + @@ -40,4 +41,4 @@ - \ No newline at end of file + diff --git a/android/app/src/main/java/com/anonymous/FlightWorkApp/MainApplication.kt b/android/app/src/main/java/com/anonymous/FlightWorkApp/MainApplication.kt index f8076cb..bf2f01e 100644 --- a/android/app/src/main/java/com/anonymous/FlightWorkApp/MainApplication.kt +++ b/android/app/src/main/java/com/anonymous/FlightWorkApp/MainApplication.kt @@ -15,9 +15,12 @@ import com.facebook.react.defaults.DefaultReactNativeHost import expo.modules.ApplicationLifecycleDispatcher import expo.modules.ReactNativeHostWrapper +import com.anonymous.FlightWorkApp.runtime.RuntimeDiagnostics +import com.anonymous.FlightWorkApp.runtime.RuntimeDiagnosticsPackage import com.anonymous.FlightWorkApp.wear.WearDataSenderPackage class MainApplication : Application(), ReactApplication { + private val startupState by lazy { RuntimeDiagnostics.prepareStartup(this) } override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper( this, @@ -25,6 +28,7 @@ class MainApplication : Application(), ReactApplication { override fun getPackages(): List = PackageList(this).packages.apply { add(WearDataSenderPackage()) + add(RuntimeDiagnosticsPackage()) // Packages that cannot be autolinked yet can be added manually here, for example: // add(MyReactNativePackage()) } @@ -42,6 +46,7 @@ class MainApplication : Application(), ReactApplication { override fun onCreate() { super.onCreate() + startupState DefaultNewArchitectureEntryPoint.releaseLevel = try { ReleaseLevel.valueOf(BuildConfig.REACT_NATIVE_RELEASE_LEVEL.uppercase()) } catch (e: IllegalArgumentException) { diff --git a/android/app/src/main/java/com/anonymous/FlightWorkApp/runtime/RuntimeDiagnostics.kt b/android/app/src/main/java/com/anonymous/FlightWorkApp/runtime/RuntimeDiagnostics.kt new file mode 100644 index 0000000..689ef58 --- /dev/null +++ b/android/app/src/main/java/com/anonymous/FlightWorkApp/runtime/RuntimeDiagnostics.kt @@ -0,0 +1,965 @@ +package com.anonymous.FlightWorkApp.runtime + +import android.app.ActivityManager +import android.app.Application +import android.app.ApplicationExitInfo +import android.content.ContentUris +import android.content.Context +import android.content.ContentValues +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import com.anonymous.FlightWorkApp.BuildConfig +import org.json.JSONObject +import java.io.File +import java.io.PrintWriter +import java.io.StringWriter +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.system.exitProcess + +object RuntimeDiagnostics { + private const val PREFS_NAME = "runtime_diagnostics" + private const val KEY_LAST_REPORT = "last_report" + private const val KEY_STARTUP_PENDING = "startup_pending" + private const val KEY_STARTUP_STARTED_AT = "startup_started_at" + private const val KEY_STARTUP_COMPLETED_AT = "startup_completed_at" + private const val KEY_RUNTIME_VERSION = "runtime_version" + private const val KEY_LAST_EXIT_INFO = "last_exit_info" + private const val KEY_LAST_PROCESSED_EXIT_TIMESTAMP = "last_processed_exit_timestamp" + private const val LOG_FILE_NAME = "runtime-events.log" + private const val PUBLIC_LOG_FILE_NAME = "AeroStaffPro-runtime-events.log" + private const val LOG_MIME_TYPE = "text/plain" + private const val MAX_EXIT_TRACE_LINES = 120 + private const val MAX_EXIT_TRACE_CHARS = 12000 + + @Volatile + private var installed = false + + data class ExitInfoSnapshot( + val timestamp: Long, + val reasonCode: Int, + val reasonLabel: String, + val status: Int, + val importance: Int, + val processName: String?, + val description: String?, + val pssKb: Long, + val rssKb: Long, + val traceAvailable: Boolean, + val traceExcerpt: String?, + ) { + fun toJson(): JSONObject = + JSONObject().apply { + put("timestamp", timestamp) + put("reasonCode", reasonCode) + put("reasonLabel", reasonLabel) + put("status", status) + put("importance", importance) + put("processName", processName ?: JSONObject.NULL) + put("description", description ?: JSONObject.NULL) + put("pssKb", pssKb) + put("rssKb", rssKb) + put("traceAvailable", traceAvailable) + put("traceExcerpt", traceExcerpt ?: JSONObject.NULL) + } + } + + @Synchronized + fun prepareStartup(application: Application) { + val prefs = prefs(application) + val currentVersion = BuildConfig.VERSION_NAME + if (prefs.getString(KEY_RUNTIME_VERSION, null) != currentVersion) { + prefs.edit() + .putString(KEY_RUNTIME_VERSION, currentVersion) + .apply() + } + + val startupWasPending = prefs.getBoolean(KEY_STARTUP_PENDING, false) + val previousStartupStartedAt = prefs.getLong(KEY_STARTUP_STARTED_AT, 0L) + val lastProcessedExitTimestamp = prefs.getLong(KEY_LAST_PROCESSED_EXIT_TIMESTAMP, 0L) + val lastExitInfo = if (startupWasPending) { + loadLatestExitInfo( + context = application, + startedAt = previousStartupStartedAt, + processedAfter = lastProcessedExitTimestamp, + ) + } else { + null + } + if (lastExitInfo != null) { + prefs.edit() + .putString(KEY_LAST_EXIT_INFO, lastExitInfo.toJson().toString()) + .putLong(KEY_LAST_PROCESSED_EXIT_TIMESTAMP, lastExitInfo.timestamp) + .apply() + } + + if (startupWasPending) { + recordEvent( + context = application, + type = "startup_recovery", + message = "Previous startup did not complete. Recovery event recorded.", + stack = lastExitInfo?.traceExcerpt, + threadName = "main", + metadata = buildMap { + put("reason", "startup_not_completed") + if (previousStartupStartedAt > 0L) { + put("previousStartupStartedAt", previousStartupStartedAt.toString()) + } + lastExitInfo?.appendToMetadata(this) + }, + ) + } + + prefs.edit() + .putBoolean(KEY_STARTUP_PENDING, true) + .putLong(KEY_STARTUP_STARTED_AT, System.currentTimeMillis()) + .apply() + + if (!installed) { + val previousHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + recordNativeCrash(application, thread, throwable) + if (previousHandler != null) { + previousHandler.uncaughtException(thread, throwable) + } else { + exitProcess(10) + } + } + + installed = true + } + } + + fun clearLastReport(context: Context) { + prefs(context).edit() + .remove(KEY_LAST_REPORT) + .remove(KEY_LAST_EXIT_INFO) + .apply() + privateLogFile(context).delete() + deletePublicLogFile(context) + } + + fun markStartupCompleted(context: Context) { + prefs(context).edit() + .putBoolean(KEY_STARTUP_PENDING, false) + .putLong(KEY_STARTUP_COMPLETED_AT, System.currentTimeMillis()) + .apply() + } + + fun recordNativeCrash(context: Context, thread: Thread, throwable: Throwable) { + recordEvent( + context = context, + type = "native_crash", + message = throwable.message ?: throwable.javaClass.simpleName, + stack = stackTraceString(throwable), + threadName = thread.name, + metadata = mapOf( + "exceptionClass" to throwable.javaClass.name, + ), + ) + } + + fun recordJsError( + context: Context, + message: String, + stack: String?, + isFatal: Boolean, + source: String?, + ) { + recordEvent( + context = context, + type = if (isFatal) "js_fatal" else "js_error", + message = message, + stack = stack, + threadName = Thread.currentThread().name, + metadata = buildMap { + put("source", source ?: "unknown") + put("fatal", isFatal.toString()) + }, + ) + } + + fun recordSoftFailure( + context: Context, + type: String, + message: String, + throwable: Throwable? = null, + metadata: Map = emptyMap(), + ) { + recordEvent( + context = context, + type = type, + message = message, + stack = throwable?.let(::stackTraceString), + threadName = Thread.currentThread().name, + metadata = metadata, + ) + } + + fun getDiagnosticsJson(context: Context): String { + val prefersPublicLog = supportsPublicLogMirror() + val publicLogReady = if (prefersPublicLog) syncPublicLogFile(context) else false + val prefs = prefs(context) + val payload = JSONObject() + payload.put("appVersion", BuildConfig.VERSION_NAME) + payload.put("device", "${Build.MANUFACTURER} ${Build.MODEL}".trim()) + payload.put("androidVersion", "Android ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})") + payload.put("startupPending", prefs.getBoolean(KEY_STARTUP_PENDING, false)) + payload.put("startupStartedAt", prefs.getLong(KEY_STARTUP_STARTED_AT, 0L)) + payload.put("startupCompletedAt", prefs.getLong(KEY_STARTUP_COMPLETED_AT, 0L)) + payload.put( + "logFilePath", + when { + publicLogReady || prefersPublicLog -> publicLogDisplayPath() + else -> privateLogFile(context).absolutePath + }, + ) + + val lastReport = prefs.getString(KEY_LAST_REPORT, null) + if (!lastReport.isNullOrBlank()) { + payload.put("lastReport", JSONObject(lastReport)) + } + val lastExitInfo = prefs.getString(KEY_LAST_EXIT_INFO, null) + if (!lastExitInfo.isNullOrBlank()) { + payload.put("lastExitInfo", JSONObject(lastExitInfo)) + } + + return payload.toString() + } + + @Synchronized + private fun recordEvent( + context: Context, + type: String, + message: String, + stack: String?, + threadName: String?, + metadata: Map, + ) { + val report = JSONObject() + report.put("type", type) + report.put("message", message) + report.put("timestamp", isoNow()) + report.put("thread", threadName ?: "unknown") + report.put("appVersion", BuildConfig.VERSION_NAME) + report.put("device", "${Build.MANUFACTURER} ${Build.MODEL}".trim()) + report.put("androidVersion", "Android ${Build.VERSION.RELEASE} (SDK ${Build.VERSION.SDK_INT})") + report.put("startupPending", prefs(context).getBoolean(KEY_STARTUP_PENDING, false)) + if (!stack.isNullOrBlank()) { + report.put("stack", stack) + } + if (metadata.isNotEmpty()) { + val metadataJson = JSONObject() + metadata.forEach { (key, value) -> metadataJson.put(key, value) } + report.put("metadata", metadataJson) + } + + prefs(context).edit() + .putString(KEY_LAST_REPORT, report.toString()) + .apply() + + appendToLogFile(context, report.toString(2)) + } + + private fun prefs(context: Context) = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + + private fun appendToLogFile(context: Context, payload: String) { + privateLogFile(context).appendText(payload + "\n\n") + syncPublicLogFile(context) + } + + private fun privateLogFile(context: Context): File = + File(context.filesDir, LOG_FILE_NAME) + + private fun supportsPublicLogMirror(): Boolean = + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || legacyPublicLogFile() != null + + private fun syncPublicLogFile(context: Context): Boolean { + return try { + val source = privateLogFile(context) + if (!source.exists()) { + deletePublicLogFile(context) + true + } else { + val content = source.readText() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + val uri = findOrCreatePublicLogUri(context) ?: return false + val stream = context.contentResolver.openOutputStream(uri, "wt") ?: return false + stream.bufferedWriter().use { writer -> + writer.write(content) + } + true + } else { + val target = legacyPublicLogFile() ?: return false + target.parentFile?.mkdirs() + target.writeText(content) + true + } + } + } catch (_: Throwable) { + false + } + } + + private fun deletePublicLogFile(context: Context) { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + findPublicLogUri(context)?.let { uri -> + context.contentResolver.delete(uri, null, null) + } + } else { + legacyPublicLogFile()?.delete() + } + } catch (_: Throwable) { + } + } + + private fun publicLogDisplayPath(): String = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + "Download/$PUBLIC_LOG_FILE_NAME" + } else { + legacyPublicLogFile()?.absolutePath ?: "Download/$PUBLIC_LOG_FILE_NAME" + } + + private fun legacyPublicLogFile(): File? = + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + ?.let { directory -> File(directory, PUBLIC_LOG_FILE_NAME) } + + private fun findOrCreatePublicLogUri(context: Context) = + findPublicLogUri(context) ?: context.contentResolver.insert( + MediaStore.Downloads.EXTERNAL_CONTENT_URI, + ContentValues().apply { + put(MediaStore.MediaColumns.DISPLAY_NAME, PUBLIC_LOG_FILE_NAME) + put(MediaStore.MediaColumns.MIME_TYPE, LOG_MIME_TYPE) + put(MediaStore.MediaColumns.RELATIVE_PATH, "${Environment.DIRECTORY_DOWNLOADS}/") + }, + ) + + private fun findPublicLogUri(context: Context) = + context.contentResolver.query( + MediaStore.Downloads.EXTERNAL_CONTENT_URI, + arrayOf(MediaStore.MediaColumns._ID), + "${MediaStore.MediaColumns.DISPLAY_NAME}=? AND ${MediaStore.MediaColumns.RELATIVE_PATH}=?", + arrayOf(PUBLIC_LOG_FILE_NAME, "${Environment.DIRECTORY_DOWNLOADS}/"), + null, + )?.use { cursor -> + if (!cursor.moveToFirst()) { + return@use null + } + val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)) + ContentUris.withAppendedId(MediaStore.Downloads.EXTERNAL_CONTENT_URI, id) + } + + private fun isoNow(): String = + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSZ", Locale.US).format(Date()) + + private fun stackTraceString(throwable: Throwable): String { + val writer = StringWriter() + throwable.printStackTrace(PrintWriter(writer)) + return writer.toString() + } + + private fun loadLatestExitInfo( + context: Context, + startedAt: Long, + processedAfter: Long, + ): ExitInfoSnapshot? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + return null + } + + val activityManager = context.getSystemService(ActivityManager::class.java) ?: return null + val currentProcessName = runCatching { Application.getProcessName() } + .getOrNull() + ?.takeIf { it.isNotBlank() } + ?: context.packageName + val notBefore = maxOf(startedAt, processedAfter) + + return runCatching { + activityManager.getHistoricalProcessExitReasons(context.packageName, 0, 8) + .asSequence() + .filter { info -> + val processName = info.processName + processName == currentProcessName || processName == context.packageName + } + .filter { info -> info.timestamp > notBefore } + .maxByOrNull { info -> info.timestamp } + ?.toSnapshot() + }.getOrNull() + } + + private fun ApplicationExitInfo.toSnapshot(): ExitInfoSnapshot = + readTraceSnapshot().let { trace -> + ExitInfoSnapshot( + timestamp = timestamp, + reasonCode = reason, + reasonLabel = exitReasonLabel(reason), + status = status, + importance = importance, + processName = processName?.takeIf { it.isNotBlank() }, + description = description?.singleLineSummary(), + pssKb = pss, + rssKb = rss, + traceAvailable = trace.available, + traceExcerpt = trace.excerpt, + ) + } + + private data class ExitTraceSnapshot( + val available: Boolean, + val excerpt: String?, + ) + + private fun ApplicationExitInfo.readTraceSnapshot(): ExitTraceSnapshot = + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + ExitTraceSnapshot(available = false, excerpt = null) + } else { + runCatching { + traceInputStream?.use { stream -> + ExitTraceSnapshot( + available = true, + excerpt = stream.readTraceExcerpt(), + ) + } ?: ExitTraceSnapshot(available = false, excerpt = null) + }.getOrElse { ExitTraceSnapshot(available = false, excerpt = null) } + } + + private fun exitReasonLabel(reason: Int): String = + when (reason) { + ApplicationExitInfo.REASON_UNKNOWN -> "REASON_UNKNOWN" + ApplicationExitInfo.REASON_EXIT_SELF -> "REASON_EXIT_SELF" + ApplicationExitInfo.REASON_SIGNALED -> "REASON_SIGNALED" + ApplicationExitInfo.REASON_LOW_MEMORY -> "REASON_LOW_MEMORY" + ApplicationExitInfo.REASON_CRASH -> "REASON_CRASH" + ApplicationExitInfo.REASON_CRASH_NATIVE -> "REASON_CRASH_NATIVE" + ApplicationExitInfo.REASON_ANR -> "REASON_ANR" + ApplicationExitInfo.REASON_INITIALIZATION_FAILURE -> "REASON_INITIALIZATION_FAILURE" + ApplicationExitInfo.REASON_PERMISSION_CHANGE -> "REASON_PERMISSION_CHANGE" + ApplicationExitInfo.REASON_EXCESSIVE_RESOURCE_USAGE -> "REASON_EXCESSIVE_RESOURCE_USAGE" + ApplicationExitInfo.REASON_USER_REQUESTED -> "REASON_USER_REQUESTED" + ApplicationExitInfo.REASON_USER_STOPPED -> "REASON_USER_STOPPED" + ApplicationExitInfo.REASON_DEPENDENCY_DIED -> "REASON_DEPENDENCY_DIED" + ApplicationExitInfo.REASON_OTHER -> "REASON_OTHER" + ApplicationExitInfo.REASON_FREEZER -> "REASON_FREEZER" + ApplicationExitInfo.REASON_PACKAGE_STATE_CHANGE -> "REASON_PACKAGE_STATE_CHANGE" + ApplicationExitInfo.REASON_PACKAGE_UPDATED -> "REASON_PACKAGE_UPDATED" + else -> "REASON_$reason" + } + + private fun ExitInfoSnapshot.appendToMetadata(target: MutableMap) { + target["exitReason"] = reasonLabel + target["exitReasonCode"] = reasonCode.toString() + target["exitStatus"] = status.toString() + target["exitImportance"] = importance.toString() + target["exitTimestamp"] = timestamp.toString() + target["exitTraceAvailable"] = traceAvailable.toString() + if (processName != null) { + target["exitProcessName"] = processName + } + if (!description.isNullOrBlank()) { + target["exitDescription"] = description + } + if (pssKb > 0L) { + target["exitPssKb"] = pssKb.toString() + } + if (rssKb > 0L) { + target["exitRssKb"] = rssKb.toString() + } + } + + private fun String.singleLineSummary(): String = + replace(Regex("\\s+"), " ") + .trim() + .let { summary -> + if (summary.length <= 240) summary else "${summary.take(237)}..." + } + + private fun java.io.InputStream.readTraceExcerpt(): String? { + val bytes = readBytes() + return decodeTombstoneTrace(bytes) ?: extractPrintableStrings(bytes) + } + + private data class TombstoneSummary( + val buildFingerprint: String?, + val revision: String?, + val timestamp: String?, + val pid: Int?, + val tid: Int?, + val signal: TombstoneSignalSummary?, + val abortMessage: String?, + val causes: List, + val crashingThread: TombstoneThreadSummary?, + ) + + private data class TombstoneSignalSummary( + val number: Int?, + val name: String?, + val code: Int?, + val codeName: String?, + val faultAddress: Long?, + ) + + private data class TombstoneThreadSummary( + val mapKey: Int?, + val id: Int?, + val name: String?, + val notes: List, + val unreadableElfFiles: List, + val frames: List, + ) + + private data class TombstoneFrameSummary( + val relPc: Long?, + val pc: Long?, + val functionName: String?, + val functionOffset: Long?, + val fileName: String?, + ) + + private class ProtoReader(private val data: ByteArray) { + private var position = 0 + + fun readTag(): Int? = + if (position >= data.size) null else readVarint().toInt() + + fun readVarint(): Long { + var result = 0L + var shift = 0 + while (true) { + if (position >= data.size) { + throw IllegalStateException("Unexpected end of protobuf input.") + } + val value = data[position++].toInt() and 0xFF + result = result or ((value and 0x7F).toLong() shl shift) + if ((value and 0x80) == 0) { + return result + } + shift += 7 + if (shift > 63) { + throw IllegalStateException("Invalid protobuf varint.") + } + } + } + + fun readLengthDelimitedBytes(): ByteArray { + val length = readVarint().toInt() + if (length < 0 || position + length > data.size) { + throw IllegalStateException("Invalid protobuf length-delimited field.") + } + val end = position + length + val value = data.copyOfRange(position, end) + position = end + return value + } + + fun readString(): String = + readLengthDelimitedBytes() + .toString(Charsets.UTF_8) + .replace('\u0000', ' ') + .trim() + + fun skipField(wireType: Int) { + when (wireType) { + 0 -> readVarint() + 1 -> advance(8) + 2 -> advance(readVarint().toInt()) + 5 -> advance(4) + else -> throw IllegalStateException("Unsupported protobuf wire type: $wireType") + } + } + + private fun advance(length: Int) { + if (length < 0 || position + length > data.size) { + throw IllegalStateException("Invalid protobuf skip length.") + } + position += length + } + } + + private fun decodeTombstoneTrace(bytes: ByteArray): String? { + val summary = runCatching { parseTombstone(bytes) }.getOrNull() ?: return null + if ( + summary.signal == null && + summary.abortMessage.isNullOrBlank() && + summary.causes.isEmpty() && + summary.crashingThread?.frames.isNullOrEmpty() + ) { + return null + } + + val lines = mutableListOf() + lines += "Android native tombstone" + summary.timestamp?.takeIf { it.isNotBlank() }?.let { lines += "Timestamp: $it" } + summary.buildFingerprint?.takeIf { it.isNotBlank() }?.let { lines += "Build fingerprint: $it" } + summary.revision?.takeIf { it.isNotBlank() }?.let { lines += "Revision: $it" } + if (summary.pid != null || summary.tid != null) { + lines += "PID/TID: ${summary.pid ?: "?"}/${summary.tid ?: "?"}" + } + + summary.signal?.let { signal -> + val signalLabel = buildString { + append("Signal: ") + append(signal.number ?: "?") + signal.name?.takeIf { it.isNotBlank() }?.let { append(" ($it)") } + if (signal.code != null || !signal.codeName.isNullOrBlank()) { + append(" code ") + append(signal.code ?: "?") + signal.codeName?.takeIf { it.isNotBlank() }?.let { append(" ($it)") } + } + } + lines += signalLabel + signal.faultAddress?.let { lines += "Fault addr: ${formatHex(it)}" } + } + + summary.abortMessage?.takeIf { it.isNotBlank() }?.let { lines += "Abort message: $it" } + summary.causes.take(4).forEachIndexed { index, cause -> + lines += "Cause ${index + 1}: $cause" + } + + summary.crashingThread?.let { thread -> + lines += "" + lines += "Crashed thread: ${thread.name ?: "tid ${thread.id ?: thread.mapKey ?: "?"}"}" + thread.notes.take(4).forEach { note -> lines += "Note: $note" } + thread.unreadableElfFiles.take(4).forEach { file -> lines += "Unreadable ELF: $file" } + if (thread.frames.isNotEmpty()) { + lines += "Backtrace:" + thread.frames.take(24).forEachIndexed { index, frame -> + lines += formatFrame(index, frame) + } + } + } + + return clampTraceOutput(lines) + } + + private fun parseTombstone(bytes: ByteArray): TombstoneSummary { + val reader = ProtoReader(bytes) + var buildFingerprint: String? = null + var revision: String? = null + var timestamp: String? = null + var pid: Int? = null + var tid: Int? = null + var signal: TombstoneSignalSummary? = null + var abortMessage: String? = null + val causes = mutableListOf() + val threads = mutableListOf() + + while (true) { + val tag = reader.readTag() ?: break + val fieldNumber = tag ushr 3 + val wireType = tag and 0x07 + when (fieldNumber) { + 2 -> buildFingerprint = if (wireType == 2) reader.readString().takeIf { it.isNotBlank() } else { + reader.skipField(wireType); buildFingerprint + } + 3 -> revision = if (wireType == 2) reader.readString().takeIf { it.isNotBlank() } else { + reader.skipField(wireType); revision + } + 4 -> timestamp = if (wireType == 2) reader.readString().takeIf { it.isNotBlank() } else { + reader.skipField(wireType); timestamp + } + 5 -> pid = if (wireType == 0) reader.readVarint().toInt() else { + reader.skipField(wireType); pid + } + 6 -> tid = if (wireType == 0) reader.readVarint().toInt() else { + reader.skipField(wireType); tid + } + 10 -> if (wireType == 2) { + signal = parseSignal(reader.readLengthDelimitedBytes()) + } else { + reader.skipField(wireType) + } + 14 -> abortMessage = if (wireType == 2) { + reader.readString().takeIf { it.isNotBlank() }?.singleLineSummary() + } else { + reader.skipField(wireType); abortMessage + } + 15 -> if (wireType == 2) { + parseCause(reader.readLengthDelimitedBytes())?.let(causes::add) + } else { + reader.skipField(wireType) + } + 16 -> if (wireType == 2) { + parseThreadEntry(reader.readLengthDelimitedBytes())?.let(threads::add) + } else { + reader.skipField(wireType) + } + else -> reader.skipField(wireType) + } + } + + val crashingThread = threads.firstOrNull { it.mapKey == tid || it.id == tid } + ?: threads.firstOrNull { it.frames.isNotEmpty() } + + return TombstoneSummary( + buildFingerprint = buildFingerprint, + revision = revision, + timestamp = timestamp, + pid = pid, + tid = tid, + signal = signal, + abortMessage = abortMessage, + causes = causes, + crashingThread = crashingThread, + ) + } + + private fun parseSignal(bytes: ByteArray): TombstoneSignalSummary { + val reader = ProtoReader(bytes) + var number: Int? = null + var name: String? = null + var code: Int? = null + var codeName: String? = null + var hasFaultAddress = false + var faultAddress: Long? = null + + while (true) { + val tag = reader.readTag() ?: break + val fieldNumber = tag ushr 3 + val wireType = tag and 0x07 + when (fieldNumber) { + 1 -> number = if (wireType == 0) reader.readVarint().toInt() else { + reader.skipField(wireType); number + } + 2 -> name = if (wireType == 2) reader.readString().takeIf { it.isNotBlank() } else { + reader.skipField(wireType); name + } + 3 -> code = if (wireType == 0) reader.readVarint().toInt() else { + reader.skipField(wireType); code + } + 4 -> codeName = if (wireType == 2) reader.readString().takeIf { it.isNotBlank() } else { + reader.skipField(wireType); codeName + } + 8 -> hasFaultAddress = if (wireType == 0) reader.readVarint() != 0L else { + reader.skipField(wireType); hasFaultAddress + } + 9 -> faultAddress = if (wireType == 0) reader.readVarint() else { + reader.skipField(wireType); faultAddress + } + else -> reader.skipField(wireType) + } + } + + return TombstoneSignalSummary( + number = number, + name = name, + code = code, + codeName = codeName, + faultAddress = if (hasFaultAddress) faultAddress else null, + ) + } + + private fun parseCause(bytes: ByteArray): String? { + val reader = ProtoReader(bytes) + while (true) { + val tag = reader.readTag() ?: break + val fieldNumber = tag ushr 3 + val wireType = tag and 0x07 + when (fieldNumber) { + 1 -> if (wireType == 2) { + return reader.readString().takeIf { it.isNotBlank() }?.singleLineSummary() + } else { + reader.skipField(wireType) + } + else -> reader.skipField(wireType) + } + } + return null + } + + private fun parseThreadEntry(bytes: ByteArray): TombstoneThreadSummary? { + val reader = ProtoReader(bytes) + var mapKey: Int? = null + var thread: TombstoneThreadSummary? = null + + while (true) { + val tag = reader.readTag() ?: break + val fieldNumber = tag ushr 3 + val wireType = tag and 0x07 + when (fieldNumber) { + 1 -> mapKey = if (wireType == 0) reader.readVarint().toInt() else { + reader.skipField(wireType); mapKey + } + 2 -> if (wireType == 2) { + thread = parseThread(reader.readLengthDelimitedBytes()) + } else { + reader.skipField(wireType) + } + else -> reader.skipField(wireType) + } + } + + return thread?.copy(mapKey = mapKey) + } + + private fun parseThread(bytes: ByteArray): TombstoneThreadSummary { + val reader = ProtoReader(bytes) + var id: Int? = null + var name: String? = null + val notes = mutableListOf() + val unreadableElfFiles = mutableListOf() + val frames = mutableListOf() + + while (true) { + val tag = reader.readTag() ?: break + val fieldNumber = tag ushr 3 + val wireType = tag and 0x07 + when (fieldNumber) { + 1 -> id = if (wireType == 0) reader.readVarint().toInt() else { + reader.skipField(wireType); id + } + 2 -> name = if (wireType == 2) reader.readString().takeIf { it.isNotBlank() } else { + reader.skipField(wireType); name + } + 4 -> if (wireType == 2) { + parseBacktraceFrame(reader.readLengthDelimitedBytes())?.let(frames::add) + } else { + reader.skipField(wireType) + } + 7 -> if (wireType == 2) { + reader.readString().takeIf { it.isNotBlank() }?.let(notes::add) + } else { + reader.skipField(wireType) + } + 9 -> if (wireType == 2) { + reader.readString().takeIf { it.isNotBlank() }?.let(unreadableElfFiles::add) + } else { + reader.skipField(wireType) + } + else -> reader.skipField(wireType) + } + } + + return TombstoneThreadSummary( + mapKey = null, + id = id, + name = name, + notes = notes, + unreadableElfFiles = unreadableElfFiles, + frames = frames, + ) + } + + private fun parseBacktraceFrame(bytes: ByteArray): TombstoneFrameSummary? { + val reader = ProtoReader(bytes) + var relPc: Long? = null + var pc: Long? = null + var functionName: String? = null + var functionOffset: Long? = null + var fileName: String? = null + + while (true) { + val tag = reader.readTag() ?: break + val fieldNumber = tag ushr 3 + val wireType = tag and 0x07 + when (fieldNumber) { + 1 -> relPc = if (wireType == 0) reader.readVarint() else { + reader.skipField(wireType); relPc + } + 2 -> pc = if (wireType == 0) reader.readVarint() else { + reader.skipField(wireType); pc + } + 4 -> functionName = if (wireType == 2) reader.readString().takeIf { it.isNotBlank() } else { + reader.skipField(wireType); functionName + } + 5 -> functionOffset = if (wireType == 0) reader.readVarint() else { + reader.skipField(wireType); functionOffset + } + 6 -> fileName = if (wireType == 2) reader.readString().takeIf { it.isNotBlank() } else { + reader.skipField(wireType); fileName + } + else -> reader.skipField(wireType) + } + } + + return if ( + relPc == null && + pc == null && + functionName.isNullOrBlank() && + fileName.isNullOrBlank() + ) { + null + } else { + TombstoneFrameSummary( + relPc = relPc, + pc = pc, + functionName = functionName, + functionOffset = functionOffset, + fileName = fileName, + ) + } + } + + private fun formatFrame(index: Int, frame: TombstoneFrameSummary): String { + val address = frame.relPc?.let(::formatHex) ?: frame.pc?.let(::formatHex) ?: "?" + val library = frame.fileName ?: "" + val symbol = frame.functionName?.takeIf { it.isNotBlank() }?.let { functionName -> + frame.functionOffset?.let { "$functionName+${formatHex(it)}" } ?: functionName + } ?: "" + return buildString { + append('#') + append(index.toString().padStart(2, '0')) + append(" pc ") + append(address) + append(' ') + append(library) + append(" (") + append(symbol) + append(')') + } + } + + private fun formatHex(value: Long): String = "0x${value.toString(16)}" + + private fun clampTraceOutput(lines: List): String? { + val output = buildString { + var totalChars = 0 + lines.forEachIndexed { index, line -> + val normalized = line.trimEnd() + val extraChars = normalized.length + if (index > 0) 1 else 0 + if (index >= MAX_EXIT_TRACE_LINES || totalChars + extraChars > MAX_EXIT_TRACE_CHARS) { + if (isNotEmpty()) { + append('\n') + } + append("... (trace truncated)") + return@buildString + } + if (isNotEmpty()) { + append('\n') + } + append(normalized) + totalChars += extraChars + } + }.trim() + return output.takeIf { it.isNotBlank() } + } + + private fun extractPrintableStrings(bytes: ByteArray): String? { + val strings = mutableListOf() + val current = StringBuilder() + + fun flush() { + val value = current.toString().trim() + if (value.length >= 4) { + strings += value + } + current.clear() + } + + bytes.forEach { byte -> + val value = byte.toInt() and 0xFF + val ch = value.toChar() + if (ch in ' '..'~' || ch == '\n' || ch == '\r' || ch == '\t') { + current.append(ch) + } else { + flush() + } + } + flush() + + return clampTraceOutput(strings.take(MAX_EXIT_TRACE_LINES)) + } +} diff --git a/android/app/src/main/java/com/anonymous/FlightWorkApp/runtime/RuntimeDiagnosticsModule.kt b/android/app/src/main/java/com/anonymous/FlightWorkApp/runtime/RuntimeDiagnosticsModule.kt new file mode 100644 index 0000000..b1e2d55 --- /dev/null +++ b/android/app/src/main/java/com/anonymous/FlightWorkApp/runtime/RuntimeDiagnosticsModule.kt @@ -0,0 +1,45 @@ +package com.anonymous.FlightWorkApp.runtime + +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod + +class RuntimeDiagnosticsModule(reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { + + override fun getName(): String = "RuntimeDiagnostics" + + override fun getConstants(): MutableMap = mutableMapOf( + "initialDiagnosticsJson" to RuntimeDiagnostics.getDiagnosticsJson(reactApplicationContext), + ) + + @ReactMethod + fun getRuntimeDiagnostics(promise: Promise) { + promise.resolve(RuntimeDiagnostics.getDiagnosticsJson(reactApplicationContext)) + } + + @ReactMethod + fun clearLastReport(promise: Promise) { + RuntimeDiagnostics.clearLastReport(reactApplicationContext) + promise.resolve(true) + } + + @ReactMethod + fun markStartupCompleted(promise: Promise) { + RuntimeDiagnostics.markStartupCompleted(reactApplicationContext) + promise.resolve(true) + } + + @ReactMethod + fun recordJsError( + message: String, + stack: String?, + isFatal: Boolean, + source: String?, + promise: Promise, + ) { + RuntimeDiagnostics.recordJsError(reactApplicationContext, message, stack, isFatal, source) + promise.resolve(true) + } +} diff --git a/android/app/src/main/java/com/anonymous/FlightWorkApp/runtime/RuntimeDiagnosticsPackage.kt b/android/app/src/main/java/com/anonymous/FlightWorkApp/runtime/RuntimeDiagnosticsPackage.kt new file mode 100644 index 0000000..026a4c3 --- /dev/null +++ b/android/app/src/main/java/com/anonymous/FlightWorkApp/runtime/RuntimeDiagnosticsPackage.kt @@ -0,0 +1,14 @@ +package com.anonymous.FlightWorkApp.runtime + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class RuntimeDiagnosticsPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List = + listOf(RuntimeDiagnosticsModule(reactContext)) + + override fun createViewManagers(reactContext: ReactApplicationContext): List> = + emptyList() +} diff --git a/android/app/src/main/res/drawable-hdpi/notification_icon.png b/android/app/src/main/res/drawable-hdpi/notification_icon.png index 08e632f..1bc393c 100644 Binary files a/android/app/src/main/res/drawable-hdpi/notification_icon.png and b/android/app/src/main/res/drawable-hdpi/notification_icon.png differ diff --git a/android/app/src/main/res/drawable-mdpi/notification_icon.png b/android/app/src/main/res/drawable-mdpi/notification_icon.png index 5309eb8..bfcea21 100644 Binary files a/android/app/src/main/res/drawable-mdpi/notification_icon.png and b/android/app/src/main/res/drawable-mdpi/notification_icon.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/notification_icon.png b/android/app/src/main/res/drawable-xhdpi/notification_icon.png index 73035e1..dc3b613 100644 Binary files a/android/app/src/main/res/drawable-xhdpi/notification_icon.png and b/android/app/src/main/res/drawable-xhdpi/notification_icon.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/notification_icon.png b/android/app/src/main/res/drawable-xxhdpi/notification_icon.png index 36413a3..52f7d1a 100644 Binary files a/android/app/src/main/res/drawable-xxhdpi/notification_icon.png and b/android/app/src/main/res/drawable-xxhdpi/notification_icon.png differ diff --git a/android/app/src/main/res/drawable-xxxhdpi/notification_icon.png b/android/app/src/main/res/drawable-xxxhdpi/notification_icon.png index 2db6d9d..a0f5b43 100644 Binary files a/android/app/src/main/res/drawable-xxxhdpi/notification_icon.png and b/android/app/src/main/res/drawable-xxxhdpi/notification_icon.png differ diff --git a/android/gradle.properties b/android/gradle.properties index 8e39f82..eb8b0c1 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -21,6 +21,9 @@ org.gradle.parallel=true # Android operating system, and which are packaged with your app's APK # https://developer.android.com/topic/libraries/support-library/androidx-rn android.useAndroidX=true +android.buildToolsVersion=37.0.0 +android.compileSdkVersion=37 +android.suppressUnsupportedCompileSdk=37,37.0 # Enable AAPT2 PNG crunching android.enablePngCrunchInReleaseBuilds=true diff --git a/android/wear/build.gradle b/android/wear/build.gradle index 14bdf39..78fd35e 100644 --- a/android/wear/build.gradle +++ b/android/wear/build.gradle @@ -2,14 +2,28 @@ apply plugin: 'com.android.application' apply plugin: 'org.jetbrains.kotlin.android' apply plugin: 'org.jetbrains.kotlin.plugin.compose' +def releaseStoreFile = System.getenv("RELEASE_STORE_FILE") ?: findProperty("android.releaseStoreFile") +def releaseStorePassword = System.getenv("RELEASE_STORE_PASSWORD") ?: findProperty("android.releaseStorePassword") +def releaseKeyAlias = System.getenv("RELEASE_KEY_ALIAS") ?: findProperty("android.releaseKeyAlias") +def releaseKeyPassword = System.getenv("RELEASE_KEY_PASSWORD") ?: findProperty("android.releaseKeyPassword") +def hasReleaseSigning = [releaseStoreFile, releaseStorePassword, releaseKeyAlias, releaseKeyPassword] + .every { it != null && it.toString().trim() } +def wantsReleaseBuild = gradle.startParameter.taskNames.any { it.toLowerCase().contains("release") } + +if (wantsReleaseBuild && !hasReleaseSigning) { + throw new GradleException( + "Missing release signing config. Set RELEASE_STORE_FILE, RELEASE_STORE_PASSWORD, RELEASE_KEY_ALIAS, and RELEASE_KEY_PASSWORD." + ) +} + android { namespace 'com.anonymous.flightworkapp.wear' - compileSdk 36 + compileSdk rootProject.ext.compileSdkVersion defaultConfig { applicationId 'com.anonymous.FlightWorkApp' minSdkVersion 30 - targetSdkVersion 36 + targetSdkVersion rootProject.ext.targetSdkVersion versionCode 1 versionName "1.0" } @@ -21,11 +35,21 @@ android { keyAlias 'androiddebugkey' keyPassword 'android' } + if (hasReleaseSigning) { + release { + storeFile file(releaseStoreFile) + storePassword releaseStorePassword + keyAlias releaseKeyAlias + keyPassword releaseKeyPassword + } + } } buildTypes { release { - signingConfig signingConfigs.debug + if (hasReleaseSigning) { + signingConfig signingConfigs.release + } } } diff --git a/android/wear/src/main/java/com/anonymous/flightworkapp/wear/ui/EmptyState.kt b/android/wear/src/main/java/com/anonymous/flightworkapp/wear/ui/EmptyState.kt index 31e04ab..71cd3b8 100644 --- a/android/wear/src/main/java/com/anonymous/flightworkapp/wear/ui/EmptyState.kt +++ b/android/wear/src/main/java/com/anonymous/flightworkapp/wear/ui/EmptyState.kt @@ -3,6 +3,7 @@ package com.anonymous.flightworkapp.wear.ui import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.runtime.Composable +import androidx.compose.foundation.shape.CircleShape import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign @@ -20,7 +21,18 @@ fun EmptyState() { contentAlignment = Alignment.Center ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text("✈️", fontSize = 36.sp) + Box( + modifier = Modifier + .size(32.dp) + .background(WearColors.accentBg, CircleShape), + contentAlignment = Alignment.Center + ) { + Box( + modifier = Modifier + .size(12.dp) + .background(WearColors.accentLight, CircleShape) + ) + } Spacer(Modifier.height(8.dp)) Text( "Nessun volo", diff --git a/app.json b/app.json index 79ebe4a..7f62699 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "AeroStaff Pro", "slug": "AeroStaffPro", - "version": "2.6.3", + "version": "2.6.29", "orientation": "portrait", "icon": "./assets/icon.png", "userInterfaceStyle": "light", @@ -31,7 +31,7 @@ [ "expo-notifications", { - "icon": "./assets/icon.png", + "icon": "./assets/notification-icon.png", "color": "#F47B16", "defaultChannel": "voli" } @@ -64,4 +64,4 @@ } } } -} \ No newline at end of file +} diff --git a/assets/notification-icon.png b/assets/notification-icon.png new file mode 100644 index 0000000..a0f5b43 Binary files /dev/null and b/assets/notification-icon.png differ diff --git a/package-lock.json b/package-lock.json index ab928ed..933b3eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aerostaff-pro", - "version": "2.5.0", + "version": "2.6.29", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aerostaff-pro", - "version": "2.5.0", + "version": "2.6.29", "dependencies": { "@expo/metro-runtime": "~6.1.2", "@expo/vector-icons": "^15.0.3", @@ -14,6 +14,7 @@ "@react-native-picker/picker": "2.11.4", "@types/tesseract.js": "^0.0.2", "expo": "~54.0.0", + "expo-application": "~7.0.8", "expo-blur": "~15.0.8", "expo-calendar": "~15.0.8", "expo-document-picker": "~14.0.8", @@ -21,6 +22,7 @@ "expo-font": "~14.0.11", "expo-haptics": "~15.0.8", "expo-image-picker": "~17.0.10", + "expo-intent-launcher": "~13.0.8", "expo-linear-gradient": "~15.0.8", "expo-location": "~19.0.8", "expo-notifications": "~0.32.16", @@ -84,6 +86,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1495,6 +1498,7 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -1772,6 +1776,7 @@ "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", @@ -1910,14 +1915,14 @@ "version": "9.3.0", "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause" }, "node_modules/@hapi/topo": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-5.1.0.tgz", "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "@hapi/hoek": "^9.0.0" @@ -2404,7 +2409,7 @@ "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "2.0.5", @@ -2418,7 +2423,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 8" @@ -2428,7 +2433,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@nodelib/fs.scandir": "2.1.5", @@ -2454,8 +2459,9 @@ "version": "20.1.3", "resolved": "https://registry.npmjs.org/@react-native-community/cli/-/cli-20.1.3.tgz", "integrity": "sha512-sLo8cu9JyFNfuuF1C+8NJ4DHE/PEFaXGd4enkcxi/OJjGG8+sOQrdjNQ4i+cVh/2c+ah1mEMwsYjc3z0+/MqSg==", - "dev": true, + "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "@react-native-community/cli-clean": "20.1.3", "@react-native-community/cli-config": "20.1.3", @@ -2484,7 +2490,7 @@ "version": "20.1.3", "resolved": "https://registry.npmjs.org/@react-native-community/cli-clean/-/cli-clean-20.1.3.tgz", "integrity": "sha512-sFLdLzapfC0scjgzBJJWYDY2RhHPjuuPkA5r6q0gc/UQH/izXpMpLrhh1DW84cMDraNACK0U62tU7ebNaQ1LMQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@react-native-community/cli-tools": "20.1.3", @@ -2497,7 +2503,7 @@ "version": "20.1.3", "resolved": "https://registry.npmjs.org/@react-native-community/cli-config/-/cli-config-20.1.3.tgz", "integrity": "sha512-n73nW0cG92oNF0r994pPqm0DjAShOm3F8LSffDYhJqNAno+h/csmv/37iL4NtSpmKIO8xqsG3uVTXz9X/hzNaQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@react-native-community/cli-tools": "20.1.3", @@ -2512,7 +2518,7 @@ "version": "20.1.3", "resolved": "https://registry.npmjs.org/@react-native-community/cli-config-android/-/cli-config-android-20.1.3.tgz", "integrity": "sha512-DNHDP+OWLyhKShGciBqPcxhxfp1Z/7GQcb4F+TGyCeKQAr+JdnUjRXN3X+YCU/v+g2kbYYyRJKlGabzkVvdrAw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@react-native-community/cli-tools": "20.1.3", @@ -2525,7 +2531,7 @@ "version": "20.1.3", "resolved": "https://registry.npmjs.org/@react-native-community/cli-config-apple/-/cli-config-apple-20.1.3.tgz", "integrity": "sha512-QX9B83nAfCPs0KiaYz61kAEHWr9sttooxzRzNdQwvZTwnsIpvWOT9GvMMj/19OeXiQzMJBzZX0Pgt6+spiUsDQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@react-native-community/cli-tools": "20.1.3", @@ -2538,7 +2544,7 @@ "version": "20.1.3", "resolved": "https://registry.npmjs.org/@react-native-community/cli-doctor/-/cli-doctor-20.1.3.tgz", "integrity": "sha512-EI+mAPWn255/WZ4CQohy1I049yiaxVr41C3BeQ2BCyhxODIDR8XRsLzYb1t9MfqK/C3ZncUN2mPSRXFeKPPI1w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@react-native-community/cli-config": "20.1.3", @@ -2562,7 +2568,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "restore-cursor": "^3.1.0" @@ -2575,7 +2581,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "chalk": "^4.1.0", @@ -2592,7 +2598,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -2602,7 +2608,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" @@ -2618,7 +2624,7 @@ "version": "5.4.1", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "bl": "^4.1.0", @@ -2642,7 +2648,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "onetime": "^5.1.0", @@ -2656,7 +2662,7 @@ "version": "20.1.3", "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-android/-/cli-platform-android-20.1.3.tgz", "integrity": "sha512-bzB9ELPOISuqgtDZXFPQlkuxx1YFkNx3cNgslc5ElCrk+5LeCLQLIBh/dmIuK8rwUrPcrramjeBj++Noc+TaAA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@react-native-community/cli-config-android": "20.1.3", @@ -2670,7 +2676,7 @@ "version": "20.1.3", "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-apple/-/cli-platform-apple-20.1.3.tgz", "integrity": "sha512-XJ+DqAD4hkplWVXK5AMgN7pP9+4yRSe5KfZ/b42+ofkDBI55ALlUmX+9HWE3fMuRjcotTCoNZqX2ov97cFDXpQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@react-native-community/cli-config-apple": "20.1.3", @@ -2684,7 +2690,7 @@ "version": "20.1.3", "resolved": "https://registry.npmjs.org/@react-native-community/cli-platform-ios/-/cli-platform-ios-20.1.3.tgz", "integrity": "sha512-2qL48SINotuHbZO73cgqSwqd/OWNx0xTbFSdujhpogV4p8BNwYYypfjh4vJY5qJEB5PxuoVkMXT+aCADpg9nBg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@react-native-community/cli-platform-apple": "20.1.3" @@ -2694,7 +2700,7 @@ "version": "20.1.3", "resolved": "https://registry.npmjs.org/@react-native-community/cli-server-api/-/cli-server-api-20.1.3.tgz", "integrity": "sha512-hsNsdUKZDd2T99OuNuiXz4VuvLa1UN0zcxefmPjXQgI0byrBLzzDr+o7p03sKuODSzKi2h+BMnUxiS07HACQLA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@react-native-community/cli-tools": "20.1.3", @@ -2714,7 +2720,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz", "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=4" @@ -2724,7 +2730,7 @@ "version": "6.4.0", "resolved": "https://registry.npmjs.org/open/-/open-6.4.0.tgz", "integrity": "sha512-IFenVPgF70fSm1keSd2iDBIDIBZkroLeuffXq+wKTzTJlBpesFWojV9lb8mzOfaAzM1sr7HQHuO0vtV0zYekGg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-wsl": "^1.1.0" @@ -2737,7 +2743,7 @@ "version": "6.2.3", "resolved": "https://registry.npmjs.org/ws/-/ws-6.2.3.tgz", "integrity": "sha512-jmTjYU0j60B+vHey6TfR3Z7RD61z/hmxBS3VMSGIrroOWXQEneK1zNuotOUrGyBHQj0yrpsLHPWtigEFd13ndA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "async-limiter": "~1.0.0" @@ -2747,7 +2753,7 @@ "version": "20.1.3", "resolved": "https://registry.npmjs.org/@react-native-community/cli-tools/-/cli-tools-20.1.3.tgz", "integrity": "sha512-EAn0vPCMxtHhfWk2UwLmSUfPfLUnFgC7NjiVJVTKJyVk5qGnkPfoT8te/1IUXFTysUB0F0RIi+NgDB4usFOLeA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@vscode/sudo-prompt": "^9.0.0", @@ -2766,7 +2772,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-3.1.0.tgz", "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "restore-cursor": "^3.1.0" @@ -2779,7 +2785,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "locate-path": "^6.0.0", @@ -2796,7 +2802,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "p-locate": "^5.0.0" @@ -2812,7 +2818,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-4.1.0.tgz", "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "chalk": "^4.1.0", @@ -2829,7 +2835,7 @@ "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "mime": "cli.js" @@ -2842,7 +2848,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -2852,7 +2858,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" @@ -2868,7 +2874,7 @@ "version": "5.4.1", "resolved": "https://registry.npmjs.org/ora/-/ora-5.4.1.tgz", "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "bl": "^4.1.0", @@ -2892,7 +2898,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -2908,7 +2914,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "p-limit": "^3.0.2" @@ -2924,7 +2930,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "onetime": "^5.1.0", @@ -2938,7 +2944,7 @@ "version": "20.1.3", "resolved": "https://registry.npmjs.org/@react-native-community/cli-types/-/cli-types-20.1.3.tgz", "integrity": "sha512-IdAcegf0pH1hVraxWTG1ACLkYC0LDQfqtaEf42ESyLIF3Xap70JzL/9tAlxw7lSCPZPFWhrcgU0TBc4SkC/ecw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "joi": "^17.2.1" @@ -2948,7 +2954,7 @@ "version": "9.5.0", "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": "^12.20.0 || >=14" @@ -2958,7 +2964,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "locate-path": "^6.0.0", @@ -2975,7 +2981,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "p-locate": "^5.0.0" @@ -2991,7 +2997,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -3007,7 +3013,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "p-limit": "^3.0.2" @@ -3290,7 +3296,7 @@ "version": "4.1.5", "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.5.tgz", "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "@hapi/hoek": "^9.0.0" @@ -3300,14 +3306,14 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause" }, "node_modules/@sideway/pinpoint": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause" }, "node_modules/@sinclair/typebox": { @@ -3421,8 +3427,9 @@ "version": "19.1.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz", "integrity": "sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA==", - "dev": true, + "devOptional": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.0.2" } @@ -3490,7 +3497,7 @@ "version": "9.3.2", "resolved": "https://registry.npmjs.org/@vscode/sudo-prompt/-/sudo-prompt-9.3.2.tgz", "integrity": "sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/@xmldom/xmldom": { @@ -3585,7 +3592,7 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/ansi-fragments/-/ansi-fragments-0.2.1.tgz", "integrity": "sha512-DykbNHxuXQwUDRv5ibc2b0x7uw7wmwOGLBUd5RmaQ5z8Lhx19vwvKV+FAsM5rEA6dEcHxX+/Ad5s9eF2k2bB+w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "colorette": "^1.0.7", @@ -3597,7 +3604,7 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -3607,7 +3614,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^4.1.0" @@ -3675,7 +3682,7 @@ "version": "1.2.7", "resolved": "https://registry.npmjs.org/appdirsjs/-/appdirsjs-1.2.7.tgz", "integrity": "sha512-Quji6+8kLBC3NnBeo14nPDq0+2jUs5s3/xEye+udFHumHhRk4M7aAMXp/PBJqkKYGuuyR9M/6Dq7d2AViiGmhw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/arg": { @@ -3716,7 +3723,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-1.0.0.tgz", "integrity": "sha512-+Ryf6g3BKoRc7jfp7ad8tM4TtMiaWvbF/1/sQcZPkkS7ag3D5nMBCe2UfOTONtAkaG0tO0ij3C5Lwmf1EiyjHg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=4" @@ -4019,7 +4026,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "buffer": "^5.5.0", @@ -4037,7 +4044,7 @@ "version": "2.2.2", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "bytes": "^3.1.2", @@ -4062,7 +4069,7 @@ "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ee-first": "1.1.1" @@ -4132,6 +4139,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4245,7 +4253,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -4415,14 +4423,14 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz", "integrity": "sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/command-exists": { "version": "1.2.9", "resolved": "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz", "integrity": "sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/commander": { @@ -4528,7 +4536,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -4557,7 +4565,7 @@ "version": "9.0.1", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "env-paths": "^2.2.1", @@ -4584,14 +4592,14 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, + "devOptional": true, "license": "Python-2.0" }, "node_modules/cosmiconfig/node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -4636,14 +4644,14 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/dayjs": { "version": "1.11.20", "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -4667,7 +4675,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4855,7 +4863,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -4865,7 +4873,7 @@ "version": "7.21.0", "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.21.0.tgz", "integrity": "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "envinfo": "dist/cli.js" @@ -4878,7 +4886,7 @@ "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -4897,7 +4905,7 @@ "version": "1.5.2", "resolved": "https://registry.npmjs.org/errorhandler/-/errorhandler-1.5.2.tgz", "integrity": "sha512-kNAL7hESndBCrWwS72QyV3IVOTrVmj9D062FV5BQswNL5zEdeRmz/WJFyh6Aj/plvvSOrzddkxW57HgkZcR9Fw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "accepts": "~1.3.8", @@ -5003,7 +5011,7 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "cross-spawn": "^7.0.3", @@ -5027,7 +5035,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -5037,7 +5045,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" @@ -5054,6 +5062,7 @@ "resolved": "https://registry.npmjs.org/expo/-/expo-54.0.33.tgz", "integrity": "sha512-3yOEfAKqo+gqHcV8vKcnq0uA5zxlohnhA3fu4G43likN8ct5ZZ3LjAh9wDdKteEkoad3tFPvwxmXW711S5OHUw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "54.0.23", @@ -5169,6 +5178,7 @@ "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-14.0.11.tgz", "integrity": "sha512-ga0q61ny4s/kr4k8JX9hVH69exVSIfcIc19+qZ7gt71Mqtm7xy2c6kwsPTCyhBW2Ro5yXTT8EaZOpuRi35rHbg==", "license": "MIT", + "peer": true, "dependencies": { "fontfaceobserver": "^2.1.0" }, @@ -5208,6 +5218,15 @@ "expo": "*" } }, + "node_modules/expo-intent-launcher": { + "version": "13.0.8", + "resolved": "https://registry.npmjs.org/expo-intent-launcher/-/expo-intent-launcher-13.0.8.tgz", + "integrity": "sha512-sgGFotttKKN6dIatjOEJT8M6Arfakus7vIxgshg5VkxarVhZBGJzOJam7rbUlB1O/gQ8em9G8vhEU9AfjEIe7A==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-linear-gradient": { "version": "15.0.8", "resolved": "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.8.tgz", @@ -5594,7 +5613,7 @@ "version": "3.3.3", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@nodelib/fs.stat": "^2.0.2", @@ -5617,7 +5636,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -5633,7 +5652,7 @@ "version": "5.5.9", "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.9.tgz", "integrity": "sha512-jldvxr1MC6rtiZKgrFnDSvT8xuH+eJqxqOBThUVjYrxssYTo1avZLGql5l0a0BAERR01CadYzZ83kVEkbyDg+g==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -5654,7 +5673,7 @@ "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "reusify": "^1.0.4" @@ -5823,7 +5842,7 @@ "version": "8.1.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-8.1.0.tgz", "integrity": "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", @@ -5940,7 +5959,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10" @@ -5979,7 +5998,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -6196,7 +6215,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=10.17.0" @@ -6212,7 +6231,7 @@ "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -6279,7 +6298,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -6296,7 +6315,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=4" @@ -6372,7 +6391,7 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/is-callable": { @@ -6421,7 +6440,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -6459,7 +6478,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -6472,7 +6491,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-1.0.0.tgz", "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -6534,7 +6553,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -6562,7 +6581,7 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz", "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=10" @@ -6824,7 +6843,7 @@ "version": "17.13.3", "resolved": "https://registry.npmjs.org/joi/-/joi-17.13.3.tgz", "integrity": "sha512-otDA4ldcIx+ZXsKHWmp0YizCweVRZG96J10b0FevjfuncLO1oX59THoAmHkNubYJ+9gWsYsp5k8v4ib6oDv1fA==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "@hapi/hoek": "^9.3.0", @@ -6875,7 +6894,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/json5": { @@ -6894,7 +6913,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, + "devOptional": true, "license": "MIT", "optionalDependencies": { "graceful-fs": "^4.1.6" @@ -6922,7 +6941,7 @@ "version": "2.13.2", "resolved": "https://registry.npmjs.org/launch-editor/-/launch-editor-2.13.2.tgz", "integrity": "sha512-4VVDnbOpLXy/s8rdRCSXb+zfMeFR0WlJWpET1iA9CQdlZDfwyLjUuGQzXU4VeOoey6AicSAluWan7Etga6Kcmg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "picocolors": "^1.1.1", @@ -7335,7 +7354,7 @@ "version": "0.7.1", "resolved": "https://registry.npmjs.org/logkitty/-/logkitty-0.7.1.tgz", "integrity": "sha512-/3ER20CTTbahrCrpYfPn7Xavv9diBROZpoXGVZDWMw4b/X4uuUwAC0ki85tgsdMRONURyIJbcOvS94QsUBYPbQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-fragments": "^0.2.1", @@ -7350,7 +7369,7 @@ "version": "5.3.1", "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -7360,7 +7379,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -7372,7 +7391,7 @@ "version": "6.2.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -7387,14 +7406,14 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/logkitty/node_modules/yargs": { "version": "15.4.1", "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "cliui": "^6.0.0", @@ -7417,7 +7436,7 @@ "version": "18.1.3", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", - "dev": true, + "devOptional": true, "license": "ISC", "dependencies": { "camelcase": "^5.0.0", @@ -7476,7 +7495,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -7510,7 +7529,7 @@ "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 8" @@ -7973,7 +7992,7 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/nocache/-/nocache-3.0.4.tgz", "integrity": "sha512-WDD0bdg9mbq6F4mRxEYcPWwfA1vxd0mrvKOyxI7Xj/atfRHVeutzuWByG//jfm4uPzp0y4Kj051EORCBSQMycw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -8032,7 +8051,7 @@ "version": "1.15.0", "resolved": "https://registry.npmjs.org/node-stream-zip/-/node-stream-zip-1.15.0.tgz", "integrity": "sha512-LN4fydt9TqhZhThkZIVQnF9cwjU3qmUH9h78Mx/K7d3VvfRqqwthLwJEUOEL0QPZ0XQmNN7be5Ggit5+4dq3Bw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -8070,7 +8089,7 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "path-key": "^3.0.0" @@ -8110,7 +8129,7 @@ "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -8380,7 +8399,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -8393,7 +8412,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", @@ -8442,7 +8461,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.2.0.tgz", "integrity": "sha512-DwmPWeFn+tq7TiyJ2CxezCAirXjFxvaiD03npak3cRjlP9+OjTmSy1EpIrEbh+l6JgUundniloMLDQ/6VTdhLQ==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -8528,6 +8547,7 @@ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -8726,7 +8746,7 @@ "version": "6.15.0", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", - "dev": true, + "devOptional": true, "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -8751,7 +8771,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -8781,7 +8801,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "bytes": "~3.1.2", @@ -8813,6 +8833,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8832,6 +8853,7 @@ "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" }, @@ -8850,6 +8872,7 @@ "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.81.5.tgz", "integrity": "sha512-1w+/oSjEXZjMqsIvmkCRsOc8UBYv163bTWKTI8+1mxztvQPhCRYGTvZ/PL1w16xXHneIj/SLGfxWg2GWN2uexw==", "license": "MIT", + "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.7.0", "@react-native/assets-registry": "0.81.5", @@ -8992,6 +9015,7 @@ "resolved": "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.16.1.tgz", "integrity": "sha512-If0eHhoEdOYDcHsX+xBFwHMbWBGK1BvGDQDQdVkwtSIXiq1uiqjkpWVP2uQ1as94J0CzvFE9PUNDuhiX0Z6ubw==", "license": "MIT", + "peer": true, "dependencies": { "escape-string-regexp": "^4.0.0", "invariant": "2.2.4" @@ -9090,6 +9114,7 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -9098,7 +9123,7 @@ "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -9205,7 +9230,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/requireg": { @@ -9291,7 +9316,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "iojs": ">=1.0.0", @@ -9361,7 +9386,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -9422,7 +9447,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/sax": { @@ -9558,7 +9583,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/set-function-length": { @@ -9627,7 +9652,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -9647,7 +9672,7 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -9664,7 +9689,7 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -9683,7 +9708,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -9747,7 +9772,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-2.1.0.tgz", "integrity": "sha512-Qu+VC3EwYLldKa1fCxuuvULvSJOKEgk9pi8dZeCVK7TqBfUNTH4sFkk4joj8afVSfAYgJoSOetjx9QWOJ5mYoQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "ansi-styles": "^3.2.0", @@ -9762,7 +9787,7 @@ "version": "3.2.1", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "color-convert": "^1.9.0" @@ -9775,7 +9800,7 @@ "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "color-name": "1.1.3" @@ -9785,14 +9810,14 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=4" @@ -9911,14 +9936,14 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/strict-url-sanitise/-/strict-url-sanitise-0.0.1.tgz", "integrity": "sha512-nuFtF539K8jZg3FjaWH/L8eocCR6gegz5RDOsaWxfdbF5Jqr2VXWxZayjTwUzsWJDC91k2EbnJXp6FuWW+Z4hg==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "safe-buffer": "~5.2.0" @@ -9954,7 +9979,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -9973,7 +9998,7 @@ "version": "2.2.2", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz", "integrity": "sha512-DnR90I+jtXNSTXWdwrEy9FakW7UX+qUZg28gj5fk2vxxl7uS/3bpI4fjFYVmdK9etptYBPNkpahuQnEwhwECqA==", - "dev": true, + "devOptional": true, "funding": [ { "type": "github", @@ -10318,7 +10343,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "content-type": "^1.0.5", @@ -10333,7 +10358,7 @@ "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -10343,7 +10368,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "mime-db": "^1.54.0" @@ -10362,6 +10387,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10455,7 +10481,7 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">= 4.0.0" @@ -10517,7 +10543,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/utils-merge": { @@ -10650,7 +10676,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", - "dev": true, + "devOptional": true, "license": "ISC" }, "node_modules/which-typed-array": { diff --git a/package.json b/package.json index 78631a4..ff73029 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,15 @@ { "name": "aerostaff-pro", - "version": "2.5.0", + "version": "2.6.29", "main": "index.ts", "scripts": { "start": "expo start", "android": "expo run:android", "ios": "expo run:ios", "web": "expo start --web", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "check:env": "bash ./scripts/check-env.sh", + "release:apk": "bash ./scripts/release-apk.sh" }, "dependencies": { "@expo/metro-runtime": "~6.1.2", @@ -16,6 +18,7 @@ "@react-native-picker/picker": "2.11.4", "@types/tesseract.js": "^0.0.2", "expo": "~54.0.0", + "expo-application": "~7.0.8", "expo-blur": "~15.0.8", "expo-calendar": "~15.0.8", "expo-document-picker": "~14.0.8", @@ -23,6 +26,7 @@ "expo-font": "~14.0.11", "expo-haptics": "~15.0.8", "expo-image-picker": "~17.0.10", + "expo-intent-launcher": "~13.0.8", "expo-linear-gradient": "~15.0.8", "expo-location": "~19.0.8", "expo-notifications": "~0.32.16", diff --git a/scripts/check-env.sh b/scripts/check-env.sh new file mode 100644 index 0000000..7d3bc9d --- /dev/null +++ b/scripts/check-env.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +if ! command -v java >/dev/null 2>&1; then + echo "[check-env] java non trovato nel PATH" + exit 1 +fi + +JAVA_VER_RAW="$(java -version 2>&1 | head -n 1)" +JAVA_MAJOR="$(echo "$JAVA_VER_RAW" | sed -E 's/.*version "([0-9]+).*/\1/')" + +if [[ "$JAVA_MAJOR" != "17" && "$JAVA_MAJOR" != "21" ]]; then + echo "[check-env] Java non supportata: $JAVA_VER_RAW" + echo "[check-env] Usa JDK 17 o 21 per build Android release" + exit 1 +fi + +if [[ ! -x "$ROOT_DIR/android/gradlew" ]]; then + echo "[check-env] gradlew non trovato o non eseguibile in android/" + exit 1 +fi + +echo "[check-env] OK - Java $JAVA_MAJOR compatibile" diff --git a/scripts/release-apk.sh b/scripts/release-apk.sh new file mode 100644 index 0000000..6d73f59 --- /dev/null +++ b/scripts/release-apk.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APK_PATH="$ROOT_DIR/android/app/build/outputs/apk/release/app-release.apk" + +"$ROOT_DIR/scripts/check-env.sh" + +pushd "$ROOT_DIR/android" >/dev/null +./gradlew clean assembleRelease +popd >/dev/null + +if [[ -f "$APK_PATH" ]]; then + echo "[release-apk] APK generato: $APK_PATH" +else + echo "[release-apk] Build completata ma APK non trovato in: $APK_PATH" + exit 1 +fi diff --git a/src/components/DrawerMenu.tsx b/src/components/DrawerMenu.tsx index e22e073..aeac33e 100644 --- a/src/components/DrawerMenu.tsx +++ b/src/components/DrawerMenu.tsx @@ -3,11 +3,12 @@ import React, { useEffect, useRef, useState, useMemo } from 'react'; import { Animated, Modal, StyleSheet, Text, TouchableOpacity, View, } from 'react-native'; +import { Easing } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; -import { BlurView } from 'expo-blur'; import { LinearGradient } from 'expo-linear-gradient'; import { useAppTheme, type ThemeColors } from '../context/ThemeContext'; import AeroStaffLogo from './AeroStaffLogo'; +import FrostedSurface from './FrostedSurface'; import { useLanguage } from '../context/LanguageContext'; type DrawerItem = { @@ -44,13 +45,34 @@ export default function DrawerMenu({ visible, onClose, onSelect }: Props) { if (visible) { setMounted(true); Animated.parallel([ - Animated.spring(slideAnim, { toValue: 0, damping: 22, stiffness: 200, useNativeDriver: false }), - Animated.timing(fadeAnim, { toValue: 1, duration: 250, useNativeDriver: true }), + Animated.spring(slideAnim, { + toValue: 0, + damping: 24, + stiffness: 185, + mass: 0.95, + useNativeDriver: false, + }), + Animated.timing(fadeAnim, { + toValue: 1, + duration: 280, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), ]).start(); } else { Animated.parallel([ - Animated.timing(slideAnim, { toValue: -DRAWER_WIDTH, duration: 220, useNativeDriver: false }), - Animated.timing(fadeAnim, { toValue: 0, duration: 220, useNativeDriver: true }), + Animated.timing(slideAnim, { + toValue: -DRAWER_WIDTH, + duration: 260, + easing: Easing.inOut(Easing.cubic), + useNativeDriver: false, + }), + Animated.timing(fadeAnim, { + toValue: 0, + duration: 220, + easing: Easing.out(Easing.quad), + useNativeDriver: true, + }), ]).start(({ finished }) => { if (finished) setMounted(false); }); } }, [visible]); @@ -67,15 +89,16 @@ export default function DrawerMenu({ visible, onClose, onSelect }: Props) { {/* Drawer */} - - {/* Glass overlay tint */} - - {/* Orange gradient header */} AeroStaff Pro Β· v{version} - + @@ -142,9 +165,6 @@ function makeStyles(c: ThemeColors) { blurFill: { ...StyleSheet.absoluteFillObject, }, - glassTint: { - ...StyleSheet.absoluteFillObject, - }, headerGradient: { flexDirection: 'row', alignItems: 'center', @@ -155,7 +175,8 @@ function makeStyles(c: ThemeColors) { }, closeIconBtn: { padding: 6 }, sectionLabel: { - fontSize: 10, fontWeight: '700', color: c.textMuted, + fontSize: 10, fontWeight: '700', + color: c.isDark ? 'rgba(229,233,240,0.72)' : c.textMuted, letterSpacing: 1.4, paddingHorizontal: 20, paddingTop: 20, paddingBottom: 8, }, items: { paddingHorizontal: 10 }, @@ -163,6 +184,7 @@ function makeStyles(c: ThemeColors) { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 13, paddingHorizontal: 10, borderRadius: 16, marginBottom: 2, + backgroundColor: c.isDark ? 'rgba(255,255,255,0.04)' : 'rgba(255,255,255,0.62)', }, itemIcon: { width: 42, height: 42, borderRadius: 14, @@ -170,8 +192,8 @@ function makeStyles(c: ThemeColors) { justifyContent: 'center', alignItems: 'center', }, itemLabel: { fontSize: 14, fontWeight: '600', color: c.text }, - itemSub: { fontSize: 11, color: c.textMuted, marginTop: 1 }, + itemSub: { fontSize: 11, color: c.isDark ? 'rgba(229,233,240,0.68)' : c.textMuted, marginTop: 1 }, divider: { height: 1, backgroundColor: c.border, marginHorizontal: 18, marginTop: 16 }, - version: { fontSize: 11, color: c.textMuted, textAlign: 'center', paddingTop: 14 }, + version: { fontSize: 11, color: c.isDark ? 'rgba(229,233,240,0.62)' : c.textMuted, textAlign: 'center', paddingTop: 14 }, }); } diff --git a/src/components/FrostedSurface.tsx b/src/components/FrostedSurface.tsx new file mode 100644 index 0000000..d0844a4 --- /dev/null +++ b/src/components/FrostedSurface.tsx @@ -0,0 +1,59 @@ +import React from 'react'; +import { StyleSheet, View, type ViewProps } from 'react-native'; +import { BlurView } from 'expo-blur'; +import { LinearGradient } from 'expo-linear-gradient'; + +type FrostedSurfaceProps = ViewProps & { + blurIntensity?: number; + blurTint?: 'light' | 'dark'; + gradientColors?: [string, string, ...string[]]; + baseColor?: string; + overlayColor?: string; +}; + +export default function FrostedSurface({ + children, + blurIntensity = 80, + blurTint = 'dark', + gradientColors = ['rgba(10,14,22,0.66)', 'rgba(10,14,22,0.42)'], + baseColor = 'rgba(10,14,22,0.62)', + overlayColor = 'rgba(0,0,0,0.22)', + style, + ...viewProps +}: FrostedSurfaceProps) { + return ( + + + + + + + {children} + + + ); +} + +const styles = StyleSheet.create({ + shell: { + overflow: 'hidden', + }, + content: { + flex: 1, + }, +}); diff --git a/src/components/ProfileSwitcherModal.tsx b/src/components/ProfileSwitcherModal.tsx new file mode 100644 index 0000000..1c61cab --- /dev/null +++ b/src/components/ProfileSwitcherModal.tsx @@ -0,0 +1,621 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { + Alert, + Modal, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import { MaterialIcons } from '@expo/vector-icons'; +import { useAirport, type AirportProfile } from '../context/AirportContext'; +import { useLanguage } from '../context/LanguageContext'; +import { useAppTheme, type ThemeColors } from '../context/ThemeContext'; +import { + AIRPORT_PRESETS, + formatAirportSettingLabel, + getAirportAirlines, + isValidAirportCode, + normalizeAirportCode, +} from '../utils/airportSettings'; +import { AIRLINE_COLORS, AIRLINE_DISPLAY_NAMES } from '../utils/airlineOps'; + +type Props = { + visible: boolean; + onClose: () => void; +}; + +function getProfileBadge(profile: AirportProfile): string { + const parts = profile.name.trim().split(/\s+/).filter(Boolean); + if (parts.length >= 2) { + return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); + } + + const compact = profile.name.replace(/[^a-zA-Z0-9]/g, '').toUpperCase(); + if (compact.length >= 2) { + return compact.slice(0, 2); + } + + return profile.airportCode.slice(0, 2); +} + +export default function ProfileSwitcherModal({ visible, onClose }: Props) { + const { colors } = useAppTheme(); + const { t } = useLanguage(); + const { + profiles, + activeProfile, + activeProfileId, + saveProfile, + switchProfile, + deleteProfile, + } = useAirport(); + const styles = useMemo(() => makeStyles(colors), [colors]); + const [editorProfileId, setEditorProfileId] = useState(null); + const [draftName, setDraftName] = useState(''); + const [draftAirportCode, setDraftAirportCode] = useState(''); + const [draftAirlines, setDraftAirlines] = useState([]); + const [saving, setSaving] = useState(false); + + const editingProfile = editorProfileId && editorProfileId !== 'new' + ? profiles.find(profile => profile.id === editorProfileId) ?? null + : null; + const editorOpen = editorProfileId !== null; + const normalizedAirportCode = normalizeAirportCode(draftAirportCode); + const previewAirportCode = normalizedAirportCode || activeProfile?.airportCode || AIRPORT_PRESETS[0]?.code || 'PSA'; + const availableAirlines = useMemo(() => getAirportAirlines(previewAirportCode), [previewAirportCode]); + + useEffect(() => { + if (!visible) { + setEditorProfileId(null); + setSaving(false); + } + }, [visible]); + + useEffect(() => { + if (!editorOpen) { + return; + } + + setDraftAirlines(prev => { + const valid = prev.filter(key => availableAirlines.includes(key)); + if (valid.length > 0) { + return valid; + } + return [...availableAirlines]; + }); + }, [availableAirlines, editorOpen]); + + const openEditor = (profile?: AirportProfile) => { + if (profile) { + setEditorProfileId(profile.id); + setDraftName(profile.name); + setDraftAirportCode(profile.airportCode); + setDraftAirlines(profile.airlines.length > 0 ? [...profile.airlines] : [...getAirportAirlines(profile.airportCode)]); + return; + } + + const seedAirportCode = activeProfile?.airportCode || AIRPORT_PRESETS[0]?.code || 'PSA'; + setEditorProfileId('new'); + setDraftName(''); + setDraftAirportCode(seedAirportCode); + setDraftAirlines(activeProfile?.airlines?.length ? [...activeProfile.airlines] : [...getAirportAirlines(seedAirportCode)]); + }; + + const toggleAirline = (key: string) => { + setDraftAirlines(prev => ( + prev.includes(key) + ? prev.filter(item => item !== key) + : [...prev, key] + )); + }; + + const handleSave = async () => { + const normalized = normalizeAirportCode(draftAirportCode); + if (!draftName.trim() || !isValidAirportCode(normalized) || draftAirlines.length === 0) { + Alert.alert(t('profileValidationTitle'), t('profileValidationMessage')); + return; + } + + setSaving(true); + try { + await saveProfile({ + id: editingProfile?.id, + name: draftName, + airportCode: normalized, + airlines: draftAirlines, + activate: editorProfileId === 'new' || editingProfile?.id === activeProfileId, + }); + setEditorProfileId(null); + onClose(); + } finally { + setSaving(false); + } + }; + + const handleDelete = (profile: AirportProfile) => { + Alert.alert( + t('profileDeleteTitle'), + t('profileDeleteMessage'), + [ + { text: t('cancel'), style: 'cancel' }, + { + text: t('delete'), + style: 'destructive', + onPress: async () => { + try { + await deleteProfile(profile.id); + setEditorProfileId(null); + } catch (error) { + if (error instanceof Error && error.message === 'LAST_PROFILE') { + Alert.alert(t('profileDeleteLastTitle'), t('profileDeleteLastMessage')); + } + } + }, + }, + ], + ); + }; + + const handleSwitch = async (profile: AirportProfile) => { + if (profile.id === activeProfileId) { + onClose(); + return; + } + + setSaving(true); + try { + await switchProfile(profile.id); + onClose(); + } finally { + setSaving(false); + } + }; + + return ( + + + + + + {t('profileTitle')} + {t('profileSubtitle')} + + + + + + + {!editorOpen ? ( + <> + + {profiles.map(profile => { + const isActive = profile.id === activeProfileId; + const trackedCount = profile.airlines.length > 0 + ? profile.airlines.length + : getAirportAirlines(profile.airportCode).length; + + return ( + { handleSwitch(profile).catch(() => {}); }} + activeOpacity={0.85} + > + + + {getProfileBadge(profile)} + + + + + {profile.name} + {isActive && ( + + {t('profileActive')} + + )} + + {formatAirportSettingLabel(profile.airportCode)} + {trackedCount} compagnie + + openEditor(profile)} + activeOpacity={0.8} + > + + + + ); + })} + + + + openEditor()} activeOpacity={0.85}> + + {t('profileNew')} + + + + ) : ( + <> + + {t('profileName')} + + + {t('profileAirport')} + setDraftAirportCode(normalizeAirportCode(value))} + placeholder={t('profileAirportPlaceholder')} + placeholderTextColor={colors.textMuted} + maxLength={3} + autoCapitalize="characters" + style={styles.input} + /> + + {t('profileQuickPick')} + + {AIRPORT_PRESETS.map(airport => { + const active = airport.code === previewAirportCode; + return ( + setDraftAirportCode(airport.code)} + activeOpacity={0.8} + > + {airport.code} + {airport.city} + + ); + })} + + + + {t('profileAirlines')} + + setDraftAirlines([...availableAirlines])} activeOpacity={0.8}> + {t('profileSelectAll')} + + setDraftAirlines([])} activeOpacity={0.8}> + {t('profileDeselectAll')} + + + + + + {availableAirlines.map(key => { + const checked = draftAirlines.includes(key); + const dot = AIRLINE_COLORS[key] ?? colors.primary; + const label = AIRLINE_DISPLAY_NAMES[key] ?? key; + + return ( + toggleAirline(key)} + activeOpacity={0.85} + > + + {label} + + + ); + })} + + + + + {editingProfile && ( + handleDelete(editingProfile)} + activeOpacity={0.85} + disabled={saving} + > + + {t('delete')} + + )} + + + + setEditorProfileId(null)} + activeOpacity={0.85} + disabled={saving} + > + {t('cancel')} + + + { handleSave().catch(() => {}); }} + activeOpacity={0.85} + disabled={saving} + > + + {t('save')} + + + + )} + + + + ); +} + +function makeStyles(colors: ThemeColors) { + return StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.58)', + justifyContent: 'center', + padding: 18, + }, + sheet: { + maxHeight: '86%', + borderRadius: 24, + overflow: 'hidden', + backgroundColor: colors.card, + borderWidth: 1, + borderColor: colors.border, + }, + header: { + flexDirection: 'row', + alignItems: 'flex-start', + gap: 12, + paddingHorizontal: 18, + paddingTop: 18, + paddingBottom: 14, + borderBottomWidth: 1, + borderBottomColor: colors.border, + }, + title: { + fontSize: 18, + fontWeight: '800', + color: colors.text, + }, + subtitle: { + marginTop: 4, + fontSize: 13, + lineHeight: 18, + color: colors.textSub, + }, + closeBtn: { + width: 34, + height: 34, + borderRadius: 17, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.cardSecondary, + }, + body: { + maxHeight: 540, + }, + bodyContent: { + padding: 18, + gap: 14, + }, + profileCard: { + flexDirection: 'row', + alignItems: 'center', + gap: 14, + padding: 14, + borderRadius: 18, + backgroundColor: colors.cardSecondary, + borderWidth: 1, + borderColor: colors.border, + marginBottom: 10, + }, + profileCardActive: { + borderColor: colors.primary, + backgroundColor: colors.primaryLight, + }, + profileBadge: { + width: 48, + height: 48, + borderRadius: 16, + alignItems: 'center', + justifyContent: 'center', + }, + profileBadgeText: { + fontSize: 16, + fontWeight: '800', + }, + profileTitleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 4, + }, + profileName: { + fontSize: 15, + fontWeight: '700', + color: colors.text, + flexShrink: 1, + }, + profileMeta: { + fontSize: 12, + color: colors.textSub, + marginTop: 2, + }, + activePill: { + paddingHorizontal: 8, + paddingVertical: 3, + borderRadius: 999, + backgroundColor: colors.primary, + }, + activePillText: { + fontSize: 10, + fontWeight: '800', + color: '#fff', + letterSpacing: 0.4, + }, + profileAction: { + width: 36, + height: 36, + borderRadius: 12, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: colors.card, + }, + footer: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + padding: 16, + borderTopWidth: 1, + borderTopColor: colors.border, + backgroundColor: colors.card, + }, + secondaryBtn: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingHorizontal: 14, + paddingVertical: 11, + borderRadius: 14, + backgroundColor: colors.primaryLight, + }, + secondaryBtnText: { + fontSize: 13, + fontWeight: '700', + color: colors.primary, + }, + primaryBtn: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingHorizontal: 16, + paddingVertical: 11, + borderRadius: 14, + backgroundColor: colors.primary, + }, + primaryBtnText: { + fontSize: 13, + fontWeight: '800', + color: '#fff', + }, + deleteBtn: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + paddingHorizontal: 10, + paddingVertical: 10, + }, + deleteBtnText: { + fontSize: 13, + fontWeight: '700', + color: '#DC2626', + }, + label: { + fontSize: 12, + fontWeight: '800', + color: colors.textSub, + letterSpacing: 0.5, + marginBottom: 8, + }, + input: { + borderWidth: 1, + borderColor: colors.border, + borderRadius: 14, + paddingHorizontal: 14, + paddingVertical: 12, + fontSize: 15, + color: colors.text, + backgroundColor: colors.cardSecondary, + marginBottom: 14, + }, + quickPicks: { + gap: 10, + paddingBottom: 8, + paddingRight: 8, + }, + quickPickChip: { + paddingHorizontal: 12, + paddingVertical: 10, + borderRadius: 14, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.cardSecondary, + minWidth: 82, + }, + quickPickChipActive: { + borderColor: colors.primary, + backgroundColor: colors.primaryLight, + }, + quickPickCode: { + fontSize: 12, + fontWeight: '800', + color: colors.text, + }, + quickPickCity: { + fontSize: 11, + color: colors.textSub, + marginTop: 2, + }, + airlineHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginTop: 4, + marginBottom: 8, + }, + airlineHeaderActions: { + flexDirection: 'row', + alignItems: 'center', + gap: 14, + }, + linkText: { + fontSize: 12, + fontWeight: '700', + color: colors.primary, + }, + airlineGrid: { + gap: 10, + }, + airlineChip: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + paddingHorizontal: 14, + paddingVertical: 12, + borderRadius: 14, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.cardSecondary, + }, + airlineChipActive: { + borderColor: colors.primary, + backgroundColor: colors.primaryLight, + }, + airlineDot: { + width: 10, + height: 10, + borderRadius: 5, + }, + airlineText: { + flex: 1, + fontSize: 14, + fontWeight: '600', + color: colors.text, + }, + }); +} diff --git a/src/components/ShiftTimeline.tsx b/src/components/ShiftTimeline.tsx index da7be7d..738a381 100644 --- a/src/components/ShiftTimeline.tsx +++ b/src/components/ShiftTimeline.tsx @@ -186,7 +186,7 @@ export default function ShiftTimeline({ visible, onClose, shiftStart, shiftEnd, ) : flights.length === 0 ? ( - ✈️ + Nessuna partenza nel turno ) : ( diff --git a/src/components/TimeCarouselPicker.tsx b/src/components/TimeCarouselPicker.tsx index 463c86d..6b41196 100644 --- a/src/components/TimeCarouselPicker.tsx +++ b/src/components/TimeCarouselPicker.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useEffect, useCallback } from 'react'; +import React, { useRef, useEffect, useCallback, useState } from 'react'; import { View, Text, @@ -6,11 +6,11 @@ import { StyleSheet, NativeSyntheticEvent, NativeScrollEvent, - Platform, + TouchableOpacity, } from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; -const ITEM_H = 52; +const ITEM_H = 42; const VISIBLE = 5; const PAD = ITEM_H * 2; // 2 invisible items top & bottom @@ -31,25 +31,53 @@ const WheelColumn: React.FC = ({ }) => { const scrollRef = useRef(null); const lastIndex = useRef(defaultIndex); + const [selectedIndex, setSelectedIndex] = useState(defaultIndex); - useEffect(() => { - const timer = setTimeout(() => { - scrollRef.current?.scrollTo({ y: defaultIndex * ITEM_H, animated: false }); - }, 80); - return () => clearTimeout(timer); + const clampIndex = useCallback((index: number) => ( + Math.min(Math.max(index, 0), items.length - 1) + ), [items.length]); + + const scrollToIndex = useCallback((index: number, animated: boolean) => { + scrollRef.current?.scrollTo({ y: index * ITEM_H, animated }); }, []); - 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; + const settleIndex = useCallback((nextIndex: number, animated: boolean) => { + const idx = clampIndex(nextIndex); + const changed = idx !== lastIndex.current; + lastIndex.current = idx; + setSelectedIndex(idx); + scrollToIndex(idx, animated); + if (changed) { onChange(idx); } - }, [items.length, onChange]); + }, [clampIndex, onChange, scrollToIndex]); + + const commitIndex = useCallback((nextIndex: number, animated: boolean) => { + settleIndex(nextIndex, animated); + }, [settleIndex]); + + useEffect(() => { + const nextIndex = clampIndex(defaultIndex); + lastIndex.current = nextIndex; + setSelectedIndex(nextIndex); + const timer = setTimeout(() => { + scrollToIndex(nextIndex, false); + }, 30); + return () => clearTimeout(timer); + }, [clampIndex, defaultIndex, scrollToIndex]); + + const previewFromScroll = useCallback((e: NativeSyntheticEvent) => { + const idx = clampIndex(Math.round(e.nativeEvent.contentOffset.y / ITEM_H)); + setSelectedIndex(idx); + }, [clampIndex]); + + const syncFromScroll = useCallback((e: NativeSyntheticEvent) => { + const idx = clampIndex(Math.round(e.nativeEvent.contentOffset.y / ITEM_H)); + settleIndex(idx, true); + }, [clampIndex, settleIndex]); return ( - + {/* Selection highlight */} = ({ ref={scrollRef} showsVerticalScrollIndicator={false} snapToInterval={ITEM_H} - decelerationRate="fast" + disableIntervalMomentum + decelerationRate={0.992} scrollEventThrottle={16} - onMomentumScrollEnd={onMomentumEnd} + nestedScrollEnabled + directionalLockEnabled + overScrollMode="never" + bounces={false} + alwaysBounceVertical={false} + onScroll={previewFromScroll} + onMomentumScrollEnd={syncFromScroll} + onScrollEndDrag={syncFromScroll} contentContainerStyle={{ paddingVertical: PAD }} style={{ flex: 1 }} > {items.map((label, i) => ( - + { commitIndex(i, true); }} + > {label} - + ))} {/* Top fade */} @@ -147,18 +190,18 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', justifyContent: 'center', - borderRadius: 14, + borderRadius: 12, borderWidth: 1, overflow: 'hidden', - paddingHorizontal: 12, - marginVertical: 4, + paddingHorizontal: 8, + marginVertical: 2, }, selectionRect: { position: 'absolute', - left: 4, - right: 4, + left: 3, + right: 3, height: ITEM_H, - borderRadius: 10, + borderRadius: 8, borderWidth: 1.5, zIndex: 1, }, @@ -168,15 +211,15 @@ const styles = StyleSheet.create({ justifyContent: 'center', }, itemText: { - fontSize: 26, + fontSize: 22, fontWeight: '600', fontVariant: ['tabular-nums'], }, colon: { - fontSize: 28, + fontSize: 24, fontWeight: '700', - marginHorizontal: 6, - marginBottom: 2, + marginHorizontal: 4, + marginBottom: 1, }, fade: { position: 'absolute', diff --git a/src/components/UpdateModal.tsx b/src/components/UpdateModal.tsx index b4e2392..64abb97 100644 --- a/src/components/UpdateModal.tsx +++ b/src/components/UpdateModal.tsx @@ -1,10 +1,17 @@ -import React from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { - Modal, View, Text, TouchableOpacity, ScrollView, StyleSheet, Linking, + Modal, View, Text, TouchableOpacity, ScrollView, StyleSheet, ActivityIndicator, Alert, } from 'react-native'; import { MaterialIcons } from '@expo/vector-icons'; import { useAppTheme } from '../context/ThemeContext'; import { type UpdateInfo, APP_VERSION } from '../utils/updateChecker'; +import { + downloadUpdatePackage, + getDownloadedUpdateUri, + installDownloadedUpdate, + openUnknownSourcesSettings, + openUpdateReleasePage, +} from '../utils/updateDownload'; interface Props { info: UpdateInfo; @@ -16,18 +23,129 @@ function renderNotes(raw: string): string { .replace(/^#{1,3} ?/gm, '') // strip markdown headers .replace(/\*\*(.+?)\*\*/g, '$1') // strip bold .replace(/`(.+?)`/g, '$1') // strip inline code - .replace(/πŸ“¦ AeroStaff Pro [^\n]+\n?/, '') // strip redundant title line + .replace(/^AeroStaff Pro [^\n]+\n?/m, '') // strip redundant title line .replace(/\n{3,}/g, '\n\n') .trim(); } export default function UpdateModal({ info, onDismiss }: Props) { const { colors } = useAppTheme(); + const [downloadState, setDownloadState] = useState<'idle' | 'downloading' | 'downloaded' | 'error'>('idle'); + const [progress, setProgress] = useState(null); + const [localUri, setLocalUri] = useState(null); + const [errorText, setErrorText] = useState(null); const notes = renderNotes(info.releaseNotes); + const latestVersionLabel = info.latestVersion.replace(/^v/i, ''); + const hasDirectDownload = Boolean(info.downloadUrl); + + useEffect(() => { + let active = true; + + getDownloadedUpdateUri(info).then(uri => { + if (!active || !uri) { + return; + } + + setLocalUri(uri); + setDownloadState('downloaded'); + }).catch(() => {}); + + return () => { + active = false; + }; + }, [info]); + + const statusLabel = useMemo(() => { + if (downloadState === 'downloading') { + if (progress == null) { + return 'Download in corso…'; + } + + return `Download ${Math.round(progress * 100)}%`; + } + + if (downloadState === 'downloaded') { + return 'APK pronto per l’installazione'; + } + + if (downloadState === 'error' && errorText) { + return errorText; + } + + if (!hasDirectDownload) { + return 'La release non include un APK scaricabile direttamente.'; + } + + return null; + }, [downloadState, errorText, hasDirectDownload, progress]); + + const primaryLabel = useMemo(() => { + if (!hasDirectDownload) { + return 'Apri release'; + } + + if (downloadState === 'downloading') { + return 'Scaricamento…'; + } + + if (downloadState === 'downloaded') { + return 'Installa APK'; + } + + if (downloadState === 'error') { + return 'Riprova download'; + } + + return 'Scarica in app'; + }, [downloadState, hasDirectDownload]); + + const handlePrimaryAction = async () => { + if (!hasDirectDownload) { + await openUpdateReleasePage(info); + onDismiss(); + return; + } + + if (downloadState === 'downloading') { + return; + } + + if (downloadState === 'downloaded' && localUri) { + try { + await installDownloadedUpdate(localUri); + } catch { + Alert.alert( + 'Installazione non avviata', + 'Android non ha avviato o completato l’installazione dell’APK. Verifica che AeroStaff Pro possa installare app da questa sorgente.', + [ + { text: 'Annulla', style: 'cancel' }, + { text: 'Apri impostazioni', onPress: () => { openUnknownSourcesSettings().catch(() => {}); } }, + ], + ); + } + return; + } + + try { + setDownloadState('downloading'); + setErrorText(null); + setProgress(0); + const uri = await downloadUpdatePackage(info, next => { + setProgress(next.progress); + }); + setLocalUri(uri); + setDownloadState('downloaded'); + setProgress(1); + } catch { + setDownloadState('error'); + setErrorText('Download non riuscito. Riprova oppure apri GitHub.'); + setProgress(null); + } + }; return ( - + {/* Header */} @@ -36,11 +154,34 @@ export default function UpdateModal({ info, onDismiss }: Props) { Aggiornamento disponibile - v{APP_VERSION} β†’ {info.latestVersion} + v{APP_VERSION} β†’ v{latestVersionLabel} + {statusLabel && ( + + + {downloadState === 'downloading' + ? + : } + + {statusLabel} + + + {downloadState === 'downloading' && ( + + + + )} + + )} + {/* Release notes */} {notes.length > 0 && ( - + PiΓΉ tardi { Linking.openURL(info.releaseUrl); onDismiss(); }} + onPress={() => { openUpdateReleasePage(info).catch(() => {}); }} activeOpacity={0.8} > @@ -68,12 +214,17 @@ export default function UpdateModal({ info, onDismiss }: Props) { { Linking.openURL(info.downloadUrl); onDismiss(); }} + style={[styles.btn, styles.btnPrimary, { backgroundColor: colors.primary }, downloadState === 'downloading' && styles.btnDisabled]} + onPress={() => { handlePrimaryAction().catch(() => {}); }} + disabled={downloadState === 'downloading'} activeOpacity={0.8} > - - Scarica APK + + {primaryLabel} @@ -122,6 +273,32 @@ const styles = StyleSheet.create({ fontSize: 13, lineHeight: 20, }, + statusBox: { + paddingHorizontal: 20, + paddingTop: 14, + paddingBottom: 12, + borderBottomWidth: 1, + }, + statusRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + statusText: { + flex: 1, + fontSize: 13, + fontWeight: '600', + }, + progressTrack: { + height: 8, + borderRadius: 999, + overflow: 'hidden', + marginTop: 10, + }, + progressFill: { + height: '100%', + borderRadius: 999, + }, footer: { flexDirection: 'row', alignItems: 'center', @@ -150,6 +327,9 @@ const styles = StyleSheet.create({ btnPrimary: { borderWidth: 0, }, + btnDisabled: { + opacity: 0.6, + }, btnText: { fontSize: 13, fontWeight: '700', diff --git a/src/context/AirportContext.tsx b/src/context/AirportContext.tsx index 39d548d..0150c88 100644 --- a/src/context/AirportContext.tsx +++ b/src/context/AirportContext.tsx @@ -1,19 +1,143 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; import { + AIRPORT_STORAGE_KEY, DEFAULT_AIRPORT_CODE, + getAirportAirlines, getAirportInfo, + getStoredAirportAirlineMap, getStoredAirportCode, - setStoredAirportCode, + normalizeAirportCode, type AirportInfo, } from '../utils/airportSettings'; +const PROFILE_STORAGE_KEY = 'aerostaff_airport_profiles_v1'; +const ACTIVE_PROFILE_STORAGE_KEY = 'aerostaff_active_profile_id_v1'; +const FLIGHT_FILTER_STORAGE_KEY = 'aerostaff_flight_filter_v1'; + +export type AirportProfile = { + id: string; + name: string; + airportCode: string; + airlines: string[]; + createdAt: number; + updatedAt: number; +}; + +type SaveProfileInput = { + id?: string; + name: string; + airportCode: string; + airlines: string[]; + activate?: boolean; +}; + type AirportContextValue = { airportCode: string; airport: AirportInfo; setAirportCode: (code: string) => Promise; isLoading: boolean; + profiles: AirportProfile[]; + activeProfile: AirportProfile | null; + activeProfileId: string | null; + profileInitials: string; + switchProfile: (profileId: string) => Promise; + saveProfile: (input: SaveProfileInput) => Promise; + deleteProfile: (profileId: string) => Promise; + setSelectedAirlines: (airlines: string[]) => Promise; }; +function makeProfileId(): string { + return `profile_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; +} + +function uniqueStrings(values: string[]): string[] { + return Array.from(new Set(values.map(value => value.trim().toLowerCase()).filter(Boolean))); +} + +function sanitizeAirlines(airportCode: string, airlines: string[] | undefined, fallbackToAirportDefaults = false): string[] { + const allowed = getAirportAirlines(airportCode); + if (!Array.isArray(airlines)) { + return fallbackToAirportDefaults ? [...allowed] : []; + } + + const next = uniqueStrings(airlines).filter(key => allowed.includes(key)); + if (next.length > 0) { + return next; + } + + return airlines.length === 0 && !fallbackToAirportDefaults ? [] : [...allowed]; +} + +function sanitizeProfileName(name: string, airportCode: string): string { + const trimmed = name.trim(); + if (trimmed) { + return trimmed; + } + + const airport = getAirportInfo(airportCode); + return airport.isCustom ? airport.code : airport.city; +} + +function sanitizeProfile(raw: Partial, fallbackAirportCode = DEFAULT_AIRPORT_CODE): AirportProfile | null { + const airportCode = normalizeAirportCode(raw.airportCode || fallbackAirportCode); + if (!airportCode) { + return null; + } + + const now = Date.now(); + const id = typeof raw.id === 'string' && raw.id.trim() ? raw.id : makeProfileId(); + + return { + id, + name: sanitizeProfileName(typeof raw.name === 'string' ? raw.name : '', airportCode), + airportCode, + airlines: sanitizeAirlines( + airportCode, + Array.isArray(raw.airlines) ? raw.airlines : undefined, + !Array.isArray(raw.airlines), + ), + createdAt: typeof raw.createdAt === 'number' ? raw.createdAt : now, + updatedAt: typeof raw.updatedAt === 'number' ? raw.updatedAt : now, + }; +} + +function getProfileInitials(profile: AirportProfile | null): string { + if (!profile) { + return 'MR'; + } + + const parts = profile.name + .trim() + .split(/\s+/) + .filter(Boolean); + + if (parts.length >= 2) { + return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); + } + + const compact = profile.name.replace(/[^a-zA-Z0-9]/g, '').toUpperCase(); + if (compact.length >= 2) { + return compact.slice(0, 2); + } + + return profile.airportCode.slice(0, 2); +} + +function createDefaultProfile(airportCode: string, airlines: string[] | undefined, fallbackToAirportDefaults = true): AirportProfile { + const now = Date.now(); + const airport = getAirportInfo(airportCode); + + return { + id: makeProfileId(), + name: airport.isCustom ? airport.code : airport.city, + airportCode, + airlines: sanitizeAirlines(airportCode, airlines, fallbackToAirportDefaults), + createdAt: now, + updatedAt: now, + }; +} + const defaultAirport = getAirportInfo(DEFAULT_AIRPORT_CODE); const AirportContext = createContext({ @@ -21,27 +145,239 @@ const AirportContext = createContext({ airport: defaultAirport, setAirportCode: async () => {}, isLoading: false, + profiles: [], + activeProfile: null, + activeProfileId: null, + profileInitials: 'MR', + switchProfile: async () => {}, + saveProfile: async () => createDefaultProfile(DEFAULT_AIRPORT_CODE, getAirportAirlines(DEFAULT_AIRPORT_CODE)), + deleteProfile: async () => {}, + setSelectedAirlines: async () => {}, }); export function AirportProvider({ children }: { children: React.ReactNode }) { const [airportCode, setAirportCodeState] = useState(DEFAULT_AIRPORT_CODE); + const [profiles, setProfiles] = useState([]); + const [activeProfileId, setActiveProfileId] = useState(null); const [isLoading, setIsLoading] = useState(true); + const commitState = useCallback(async (nextProfiles: AirportProfile[], preferredActiveProfileId?: string | null) => { + const activeProfile = nextProfiles.find(profile => profile.id === preferredActiveProfileId) ?? nextProfiles[0] ?? null; + const nextAirportCode = activeProfile?.airportCode ?? DEFAULT_AIRPORT_CODE; + const nextAirlines = activeProfile?.airlines ?? getAirportAirlines(nextAirportCode); + + setProfiles(nextProfiles); + setActiveProfileId(activeProfile?.id ?? null); + setAirportCodeState(nextAirportCode); + + const writes: [string, string][] = [ + [PROFILE_STORAGE_KEY, JSON.stringify(nextProfiles)], + [AIRPORT_STORAGE_KEY, nextAirportCode], + [FLIGHT_FILTER_STORAGE_KEY, JSON.stringify(nextAirlines)], + ]; + + if (activeProfile?.id) { + writes.push([ACTIVE_PROFILE_STORAGE_KEY, activeProfile.id]); + } + + await AsyncStorage.multiSet(writes); + }, []); + useEffect(() => { - getStoredAirportCode() - .then(code => setAirportCodeState(code)) - .finally(() => setIsLoading(false)); + let mounted = true; + + const hydrate = async () => { + try { + await getStoredAirportAirlineMap(); + const [storedAirportCode, filterRaw, profilesRaw, activeProfileRaw] = await Promise.all([ + getStoredAirportCode(), + AsyncStorage.getItem(FLIGHT_FILTER_STORAGE_KEY), + AsyncStorage.getItem(PROFILE_STORAGE_KEY), + AsyncStorage.getItem(ACTIVE_PROFILE_STORAGE_KEY), + ]); + + const fallbackAirportCode = normalizeAirportCode(storedAirportCode) || DEFAULT_AIRPORT_CODE; + const storedFilter = Array.isArray(JSON.parse(filterRaw ?? '[]')) + ? uniqueStrings(JSON.parse(filterRaw ?? '[]') as string[]) + : []; + const parsedProfiles = Array.isArray(JSON.parse(profilesRaw ?? 'null')) + ? (JSON.parse(profilesRaw ?? '[]') as Partial[]) + : []; + const sanitizedProfiles = parsedProfiles + .map(profile => sanitizeProfile(profile, fallbackAirportCode)) + .filter((profile): profile is AirportProfile => profile !== null); + const nextProfiles = sanitizedProfiles.length > 0 + ? sanitizedProfiles.map(profile => ({ + ...profile, + airlines: sanitizeAirlines(profile.airportCode, profile.airlines, false), + })) + : [createDefaultProfile(fallbackAirportCode, storedFilter, filterRaw === null)]; + const activeProfile = nextProfiles.find(profile => profile.id === activeProfileRaw) ?? nextProfiles[0]; + + if (!mounted) { + return; + } + + setProfiles(nextProfiles); + setActiveProfileId(activeProfile.id); + setAirportCodeState(activeProfile.airportCode); + + await AsyncStorage.multiSet([ + [PROFILE_STORAGE_KEY, JSON.stringify(nextProfiles)], + [ACTIVE_PROFILE_STORAGE_KEY, activeProfile.id], + [AIRPORT_STORAGE_KEY, activeProfile.airportCode], + [FLIGHT_FILTER_STORAGE_KEY, JSON.stringify(activeProfile.airlines)], + ]); + } catch { + if (!mounted) { + return; + } + + const fallback = createDefaultProfile(DEFAULT_AIRPORT_CODE, getAirportAirlines(DEFAULT_AIRPORT_CODE)); + setProfiles([fallback]); + setActiveProfileId(fallback.id); + setAirportCodeState(fallback.airportCode); + } finally { + if (mounted) { + setIsLoading(false); + } + } + }; + + hydrate().catch(() => { + if (mounted) { + setIsLoading(false); + } + }); + + return () => { + mounted = false; + }; }, []); + const activeProfile = useMemo( + () => profiles.find(profile => profile.id === activeProfileId) ?? null, + [profiles, activeProfileId], + ); + const setAirportCode = useCallback(async (code: string) => { - const savedCode = await setStoredAirportCode(code); - setAirportCodeState(savedCode); - }, []); + if (!activeProfileId) { + return; + } + + const normalizedCode = normalizeAirportCode(code); + if (!normalizedCode) { + throw new Error('INVALID_AIRPORT_CODE'); + } + + const now = Date.now(); + const nextProfiles = profiles.map(profile => { + if (profile.id !== activeProfileId) { + return profile; + } + + return { + ...profile, + airportCode: normalizedCode, + airlines: sanitizeAirlines(normalizedCode, profile.airlines), + updatedAt: now, + }; + }); + + await commitState(nextProfiles, activeProfileId); + }, [activeProfileId, commitState, profiles]); + + const setSelectedAirlines = useCallback(async (airlines: string[]) => { + if (!activeProfileId) { + return; + } + + const now = Date.now(); + const nextProfiles = profiles.map(profile => { + if (profile.id !== activeProfileId) { + return profile; + } + + return { + ...profile, + airlines: sanitizeAirlines(profile.airportCode, airlines), + updatedAt: now, + }; + }); + + await commitState(nextProfiles, activeProfileId); + }, [activeProfileId, commitState, profiles]); + + const switchProfile = useCallback(async (profileId: string) => { + if (!profiles.some(profile => profile.id === profileId)) { + throw new Error('PROFILE_NOT_FOUND'); + } + + await commitState(profiles, profileId); + }, [commitState, profiles]); + + const saveProfile = useCallback(async (input: SaveProfileInput) => { + const now = Date.now(); + const existing = input.id + ? profiles.find(profile => profile.id === input.id) ?? null + : null; + const nextProfile = sanitizeProfile({ + id: input.id, + name: input.name, + airportCode: input.airportCode, + airlines: input.airlines, + createdAt: existing?.createdAt ?? now, + updatedAt: now, + }); + + if (!nextProfile) { + throw new Error('INVALID_PROFILE'); + } + + const nextProfiles = existing + ? profiles.map(profile => profile.id === existing.id ? nextProfile : profile) + : [...profiles, nextProfile]; + const nextActiveProfileId = input.activate === false + ? activeProfileId ?? nextProfile.id + : nextProfile.id; + + await commitState(nextProfiles, nextActiveProfileId); + return nextProfile; + }, [activeProfileId, commitState, profiles]); + + const deleteProfile = useCallback(async (profileId: string) => { + if (profiles.length <= 1) { + throw new Error('LAST_PROFILE'); + } + + const nextProfiles = profiles.filter(profile => profile.id !== profileId); + const nextActiveProfileId = activeProfileId === profileId + ? nextProfiles[0]?.id ?? null + : activeProfileId; + + await commitState(nextProfiles, nextActiveProfileId); + }, [activeProfileId, commitState, profiles]); const airport = useMemo(() => getAirportInfo(airportCode), [airportCode]); + const profileInitials = useMemo(() => getProfileInitials(activeProfile), [activeProfile]); return ( - + {children} ); diff --git a/src/context/LanguageContext.tsx b/src/context/LanguageContext.tsx index 0a8bdc5..2356f3c 100644 --- a/src/context/LanguageContext.tsx +++ b/src/context/LanguageContext.tsx @@ -2,7 +2,7 @@ import React, { createContext, useContext, useState, useEffect, useCallback } fr 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, + type Lang, type TranslationKey, type WeatherDescriptor, } from '../i18n/translations'; const STORAGE_KEY = 'aerostaff_language_v1'; @@ -15,7 +15,7 @@ interface LanguageContextValue { weekDaysShort: string[]; weekDaysLong: string[]; locale: string; - weatherMap: Record; + weatherMap: Record; languages: typeof LANGUAGES; } diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx index 4e316ef..78dff78 100644 --- a/src/context/ThemeContext.tsx +++ b/src/context/ThemeContext.tsx @@ -18,7 +18,7 @@ export type ThemeColors = { primary: string; primaryDark: string; primaryLight: string; - // Glass tokens (liquid glass aesthetic) + // Glass tokens glass: string; glassBorder: string; glassStrong: string; diff --git a/src/hooks/useDynamicTheme.ts b/src/hooks/useDynamicTheme.ts index 8570ed4..2980b4f 100644 --- a/src/hooks/useDynamicTheme.ts +++ b/src/hooks/useDynamicTheme.ts @@ -17,7 +17,7 @@ const themes: Record = { cardBackground: 'rgba(255, 255, 255, 0.12)', textColor: '#e8f4fd', primaryButton: '#136DEC', - icon: '🌀️', + icon: 'weather-partly-cloudy', description: 'Mattina Soleggiata', }, afternoon_clear: { @@ -25,7 +25,7 @@ const themes: Record = { cardBackground: 'rgba(255, 255, 255, 0.10)', textColor: '#e8f4fd', primaryButton: '#136DEC', - icon: 'β˜€οΈ', + icon: 'weather-sunny', description: 'Pomeriggio Sereno', }, evening_clear: { @@ -33,7 +33,7 @@ const themes: Record = { cardBackground: 'rgba(19, 109, 236, 0.18)', textColor: '#ecf0f1', primaryButton: '#136DEC', - icon: 'πŸŒ…', + icon: 'weather-sunset', description: 'Tramonto', }, night_clear: { @@ -41,7 +41,7 @@ const themes: Record = { cardBackground: 'rgba(19, 109, 236, 0.15)', textColor: '#cfd8e3', primaryButton: '#136DEC', - icon: 'πŸŒ™', + icon: 'weather-night', description: 'Notte Serena', }, cloudy: { @@ -49,7 +49,7 @@ const themes: Record = { cardBackground: 'rgba(255, 255, 255, 0.08)', textColor: '#b2bec3', primaryButton: '#136DEC', - icon: '☁️', + icon: 'weather-cloudy', description: 'Nuvoloso', }, rain: { @@ -57,7 +57,7 @@ const themes: Record = { cardBackground: 'rgba(19, 109, 236, 0.12)', textColor: '#a8c5e8', primaryButton: '#136DEC', - icon: '🌧️', + icon: 'weather-rainy', description: 'Pioggia', }, default: { @@ -65,7 +65,7 @@ const themes: Record = { cardBackground: 'rgba(255, 255, 255, 0.10)', textColor: '#e8f4fd', primaryButton: '#136DEC', - icon: '✨', + icon: 'star-four-points-outline', description: 'Tema Standard', } }; diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index 777bce5..26c05cb 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -1,4 +1,5 @@ export type Lang = 'it' | 'en'; +export type WeatherDescriptor = { text: string; iconName: string }; const it = { // Navigation @@ -8,6 +9,26 @@ const it = { // Common cancel: 'Annulla', save: 'Salva', delete: 'Elimina', error: 'Errore', confirm: 'Conferma', ok: 'OK', add: 'Aggiungi', + profileTitle: 'Profili aeroporto', + profileSubtitle: 'Salva aeroporto e compagnie diverse, poi cambia profilo con un tap.', + profileName: 'Nome profilo', + profileNamePlaceholder: 'Es. Pisa Ramp o Firenze Turno', + profileAirport: 'Aeroporto', + profileAirportPlaceholder: 'Codice IATA', + profileQuickPick: 'Scelta rapida', + profileAirlines: 'Compagnie monitorate', + profileSwitch: 'Attiva', + profileEdit: 'Modifica', + profileNew: 'Nuovo profilo', + profileActive: 'Attivo', + profileSelectAll: 'Tutte', + profileDeselectAll: 'Nessuna', + profileDeleteTitle: 'Elimina profilo', + profileDeleteMessage: 'Vuoi eliminare questo profilo?', + profileDeleteLastTitle: 'Ultimo profilo', + profileDeleteLastMessage: 'Devi lasciare almeno un profilo salvato.', + profileValidationTitle: 'Profilo incompleto', + profileValidationMessage: 'Inserisci un nome e un aeroporto valido di 3 lettere.', // Settings settingsTitle: 'Impostazioni', sectionTheme: 'TEMA', @@ -47,20 +68,22 @@ const it = { // ShiftScreen shiftTitle: 'Gestione Turni', shiftSub: 'Scansiona i turni dal tabellone e sincronizzali nel calendario.', - shiftSyncTitle: '\ud83d\udcc5 Sincronizzazione Calendario', + shiftSyncTitle: 'Sincronizzazione Calendario', shiftSyncDesc: 'Seleziona gli screenshot del tuo tabellone orari...', - shiftScanBtn: '\ud83d\udcf7 Scansiona Screenshot Turni', + shiftScanBtn: 'Scansiona Screenshot Turni', shiftExtracting: 'Estrazione del testo in corso...', shiftExtractedTitle: 'Testo Estratto:', - shiftSyncBtn: '\u2705 Sincronizza nel Calendario!', + shiftSyncBtn: '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!', + shiftSyncOkTitle: 'Turni Sincronizzati!', shiftNoShifts: 'Nessun orario trovato', shiftCalErrTitle: 'Errore Calendario', // Calendar calTitle: 'Gestione Turni', calEditBtn: 'Modifica Turni', + calModeCalendar: 'Calendario', calModeMonthHours: 'Ore mese', + calMonthTotalHours: 'Totale ore del mese', calMonthShiftsCount: 'Turni lavoro: {count}', calToday: 'Oggi', calWeatherLocal: 'Meteo locale', calShiftWork: 'Turno Lavoro', calRestDay: 'Giorno di Riposo', calNoShift: 'Nessun turno per', calEditMenuTitle: 'Modifica Turni', @@ -68,7 +91,7 @@ const it = { 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', + calTypeLabel: 'TIPO', calTypeWork: 'Lavoro', calTypeRest: 'Riposo', calStartTime: 'ORARIO INIZIO', calEndTime: 'ORARIO FINE', calSaveShift: 'Salva Turno', calImportTitle: 'Importa Turni', calExtracting: 'Estrazione testo dal PDF...', @@ -84,11 +107,11 @@ const it = { calNoEmployees: 'Nessun dipendente trovato nel PDF', // Home homeToday: 'OGGI', homeCurrentShift: 'Turno Attuale', - homeShiftWork: 'Turno Lavoro \u2708\ufe0f', homeInProgress: 'IN CORSO', + homeShiftWork: 'Turno Lavoro', 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.', + homeShiftSynced: 'Turni Sincronizzati!', homeShiftsSaved: 'turni salvati.', homeNoSchedule: 'Nessun orario trovato', homeCalErr: 'Errore Calendario', homeCalendarAuth: 'Autorizza il calendario.', // Flight @@ -103,9 +126,23 @@ const it = { 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.', + flightNotifMsg1: 'Programmate {count} notifiche per il turno di oggi.', + flightNotifMsg0: 'Nessuna notifica futura trovata con i filtri attivi.', flightNotifAccessEnable: 'Attiva notifiche voli', flightNotifAccessDisable: 'Disattiva notifiche voli', + flightNotifSettingsTitle: 'Impostazioni notifiche', + flightNotifSettingsSub: 'Decidi cosa ricevere, sticky e minutaggi.', + flightNotifOnlyTracked: 'Solo compagnie monitorate', + flightNotifOnlyTrackedSub: 'Usa il filtro compagnie impostato in questa tab', + flightNotifArrivalsToggle: 'Avvisi arrivi', + flightNotifArrivalsToggleSub: 'Notifica prima dell’arrivo previsto', + flightNotifDeparturesToggle: 'Avvisi partenze', + flightNotifDeparturesToggleSub: 'Notifica prima della partenza prevista', + flightNotifShiftEndToggle: 'Avviso fine turno', + flightNotifShiftEndToggleSub: 'Promemoria alla fine del turno', + flightNotifStickyToggle: 'Notifiche sticky', + flightNotifStickyToggleSub: 'Restano visibili finchΓ© non le chiudi', + flightNotifArrivalLead: 'Minuti prima arrivo', + flightNotifDepartureLead: 'Minuti prima partenza', // Phonebook phonebookTitle: 'Rubrica', contactAdd: 'Aggiungi', contactSearch: 'Cerca nome o numero...', contactAll: 'Tutti', @@ -158,6 +195,9 @@ const it = { flightFilterAllSub: 'Mostra tutti gli operatori presenti', flightFilterSelAll: 'Seleziona tutto', flightFilterDeselAll: 'Deseleziona tutto', + updateCheckOkTitle: 'Sei aggiornato!', + updateCheckOkMessage: "AeroStaff Pro v{version} Γ¨ l'ultima versione.", + updateCheckErrorMessage: 'Impossibile contattare GitHub. Riprova piΓΉ tardi.', }; const en: typeof it = { @@ -168,6 +208,26 @@ const en: typeof it = { // Common cancel: 'Cancel', save: 'Save', delete: 'Delete', error: 'Error', confirm: 'Confirm', ok: 'OK', add: 'Add', + profileTitle: 'Airport profiles', + profileSubtitle: 'Save different airports and airline sets, then switch profiles with one tap.', + profileName: 'Profile name', + profileNamePlaceholder: 'E.g. Pisa Ramp or Florence Shift', + profileAirport: 'Airport', + profileAirportPlaceholder: 'IATA code', + profileQuickPick: 'Quick pick', + profileAirlines: 'Tracked airlines', + profileSwitch: 'Activate', + profileEdit: 'Edit', + profileNew: 'New profile', + profileActive: 'Active', + profileSelectAll: 'All', + profileDeselectAll: 'None', + profileDeleteTitle: 'Delete profile', + profileDeleteMessage: 'Do you want to delete this profile?', + profileDeleteLastTitle: 'Last profile', + profileDeleteLastMessage: 'You need to keep at least one saved profile.', + profileValidationTitle: 'Incomplete profile', + profileValidationMessage: 'Enter a name and a valid 3-letter airport code.', // Settings settingsTitle: 'Settings', sectionTheme: 'THEME', @@ -207,20 +267,22 @@ const en: typeof it = { // ShiftScreen shiftTitle: 'Shift Manager', shiftSub: 'Scan shifts from the schedule board and sync them to the calendar.', - shiftSyncTitle: '\ud83d\udcc5 Calendar Sync', + shiftSyncTitle: 'Calendar Sync', shiftSyncDesc: 'Select screenshots of your schedule board...', - shiftScanBtn: '\ud83d\udcf7 Scan Shift Screenshots', + shiftScanBtn: 'Scan Shift Screenshots', shiftExtracting: 'Extracting text...', shiftExtractedTitle: 'Extracted Text:', - shiftSyncBtn: '\u2705 Sync to Calendar!', + shiftSyncBtn: '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!', + shiftSyncOkTitle: 'Shifts Synced!', shiftNoShifts: 'No schedules found', shiftCalErrTitle: 'Calendar Error', // Calendar calTitle: 'Shift Manager', calEditBtn: 'Edit Shifts', + calModeCalendar: 'Calendar', calModeMonthHours: 'Month hours', + calMonthTotalHours: 'Total month hours', calMonthShiftsCount: 'Work shifts: {count}', calToday: 'Today', calWeatherLocal: 'Local weather', calShiftWork: 'Work Shift', calRestDay: 'Rest Day', calNoShift: 'No shift for', calEditMenuTitle: 'Edit Shifts', @@ -228,7 +290,7 @@ const en: typeof it = { 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', + calTypeLabel: 'TYPE', calTypeWork: 'Work', calTypeRest: 'Rest', calStartTime: 'START TIME', calEndTime: 'END TIME', calSaveShift: 'Save Shift', calImportTitle: 'Import Shifts', calExtracting: 'Extracting text from PDF...', @@ -244,11 +306,11 @@ const en: typeof it = { calNoEmployees: 'No employees found in PDF', // Home homeToday: 'TODAY', homeCurrentShift: 'Current Shift', - homeShiftWork: 'Work Shift \u2708\ufe0f', homeInProgress: 'IN PROGRESS', + homeShiftWork: 'Work Shift', 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.', + homeShiftSynced: 'Shifts Synced!', homeShiftsSaved: 'shifts saved.', homeNoSchedule: 'No schedule found', homeCalErr: 'Calendar Error', homeCalendarAuth: 'Authorize the calendar.', // Flight @@ -263,9 +325,23 @@ const en: typeof it = { 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.', + flightNotifMsg1: '{count} notifications scheduled for today’s shift.', + flightNotifMsg0: 'No upcoming notifications found with current filters.', flightNotifAccessEnable: 'Enable flight notifications', flightNotifAccessDisable: 'Disable flight notifications', + flightNotifSettingsTitle: 'Notification settings', + flightNotifSettingsSub: 'Choose what to receive, sticky behavior and timing.', + flightNotifOnlyTracked: 'Only tracked airlines', + flightNotifOnlyTrackedSub: 'Use the airline filter configured in this tab', + flightNotifArrivalsToggle: 'Arrival alerts', + flightNotifArrivalsToggleSub: 'Notify before scheduled arrival', + flightNotifDeparturesToggle: 'Departure alerts', + flightNotifDeparturesToggleSub: 'Notify before scheduled departure', + flightNotifShiftEndToggle: 'Shift end alert', + flightNotifShiftEndToggleSub: 'Reminder at end of shift', + flightNotifStickyToggle: 'Sticky notifications', + flightNotifStickyToggleSub: 'Stay visible until dismissed', + flightNotifArrivalLead: 'Minutes before arrival', + flightNotifDepartureLead: 'Minutes before departure', // Phonebook phonebookTitle: 'Phonebook', contactAdd: 'Add', contactSearch: 'Search name or number...', contactAll: 'All', @@ -318,6 +394,9 @@ const en: typeof it = { flightFilterAllSub: 'Show all operators at this airport', flightFilterSelAll: 'Select all', flightFilterDeselAll: 'Deselect all', + updateCheckOkTitle: 'You are up to date!', + updateCheckOkMessage: 'AeroStaff Pro v{version} is the latest version.', + updateCheckErrorMessage: 'Could not contact GitHub. Please try again later.', }; export const translations: Record = { it, en }; @@ -340,30 +419,30 @@ export const WEEKDAYS_LONG: Record = { 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 LANGUAGES: Array<{ code: Lang; label: string }> = [ + { code: 'it', label: 'Italiano' }, + { code: 'en', label: 'English' }, ]; -export const WEATHER_MAP: Record> = { +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' }, + 0: { text: 'Sereno', iconName: 'weather-sunny' }, + 1: { text: 'Poco Nuvoloso', iconName: 'weather-partly-cloudy' }, + 2: { text: 'Nuvoloso', iconName: 'weather-partly-cloudy' }, + 3: { text: 'Coperto', iconName: 'weather-cloudy' }, + 45: { text: 'Nebbia', iconName: 'weather-fog' }, + 61: { text: 'Pioggia Leggera', iconName: 'weather-rainy' }, + 63: { text: 'Pioggia', iconName: 'weather-pouring' }, + 80: { text: 'Rovesci', iconName: 'weather-rainy' }, }, 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' }, + 0: { text: 'Clear', iconName: 'weather-sunny' }, + 1: { text: 'Mostly Clear', iconName: 'weather-partly-cloudy' }, + 2: { text: 'Partly Cloudy', iconName: 'weather-partly-cloudy' }, + 3: { text: 'Overcast', iconName: 'weather-cloudy' }, + 45: { text: 'Foggy', iconName: 'weather-fog' }, + 61: { text: 'Light Rain', iconName: 'weather-rainy' }, + 63: { text: 'Rain', iconName: 'weather-pouring' }, + 80: { text: 'Showers', iconName: 'weather-rainy' }, }, }; diff --git a/src/screens/CalendarScreen.tsx b/src/screens/CalendarScreen.tsx index 8399c70..7f7b563 100644 --- a/src/screens/CalendarScreen.tsx +++ b/src/screens/CalendarScreen.tsx @@ -1,20 +1,21 @@ -import React, { useState, useEffect, useRef, useMemo } from 'react'; -import { - View, Text, StyleSheet, ActivityIndicator, ScrollView, TouchableOpacity, - PanResponder, Platform, UIManager, Animated, Dimensions, Modal, Alert, FlatList, TextInput, - Linking, -} 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 { requestWidgetUpdate } from 'react-native-android-widget'; -import { useAppTheme, type ThemeColors } from '../context/ThemeContext'; -import TimeCarouselPicker from '../components/TimeCarouselPicker'; -import { useAirport } from '../context/AirportContext'; +import React, { useState, useEffect, useMemo } from 'react'; +import { + View, Text, StyleSheet, ActivityIndicator, ScrollView, TouchableOpacity, + Platform, Modal, Alert, FlatList, TextInput, + Linking, +} 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 { MaterialCommunityIcons, MaterialIcons } from '@expo/vector-icons'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { requestWidgetUpdate } from 'react-native-android-widget'; +import { Calendar as RNCalendar, LocaleConfig, type DateData } from 'react-native-calendars'; +import { useAppTheme, type ThemeColors } from '../context/ThemeContext'; +import TimeCarouselPicker from '../components/TimeCarouselPicker'; +import { useAirport } from '../context/AirportContext'; import { fetchAirportScheduleRaw } from '../utils/fr24api'; import { getWritableCalendarId, @@ -24,47 +25,76 @@ import { import { WIDGET_SHIFT_KEY, WIDGET_CACHE_KEY } from '../widgets/widgetTaskHandler'; import type { WidgetShiftData } from '../widgets/widgetTaskHandler'; import { ShiftWidget } from '../widgets/ShiftWidget'; -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 { + getPdfExtractorHtml, parseShiftCells, + type ParsedSchedule, type ParsedEmployee, +} 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; +}; + +type DayStats = { + weatherText: string; + weatherIconName: string; + flightCount: number; +}; + +type CalendarMarkedDates = Record; + selected?: boolean; + selectedColor?: string; + selectedTextColor?: string; +}>; + +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; +} + +function toLocalIso(dateValue: string | Date): string { + const date = new Date(dateValue); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function fromIsoDate(iso: string): Date { + return new Date(`${iso}T00:00:00`); +} + +function getMonthStart(date: Date): Date { + const next = new Date(date); + next.setDate(1); + next.setHours(0, 0, 0, 0); + return next; +} + +function isSameMonth(date: Date, iso: string): boolean { + const target = fromIsoDate(iso); + return target.getFullYear() === date.getFullYear() && target.getMonth() === date.getMonth(); +} + +export default function CalendarScreen() { + const { colors } = useAppTheme(); + const { lang, t, months, weekDaysShort, weekDaysLong, locale, weatherMap } = useLanguage(); + const { airportCode, isLoading: airportLoading } = useAirport(); + const [visibleMonth, setVisibleMonth] = useState(() => getMonthStart(new Date())); + const [selectedDay, setSelectedDay] = useState(() => toLocalIso(new Date())); + 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); @@ -79,21 +109,64 @@ export default function CalendarScreen() { 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 [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 getManualPrefillForSelectedDay = () => { + const dayEvents = eventsData[selectedDay] || []; + const existingWork = dayEvents.find(event => event.title.includes('Lavoro')); + const existingRest = dayEvents.find(event => event.title.includes('Riposo')); + + if (existingWork) { + const startDate = new Date(existingWork.startDate); + const endDate = new Date(existingWork.endDate); + + return { + date: selectedDay, + type: 'Lavoro' as const, + startH: startDate.getHours(), + startM: startDate.getMinutes(), + endH: endDate.getHours(), + endM: endDate.getMinutes(), + }; + } + + if (existingRest) { + return { + date: selectedDay, + type: 'Riposo' as const, + startH: 8, + startM: 0, + endH: 16, + endM: 0, + }; + } + + return { + date: selectedDay, + type: 'Lavoro' as const, + startH: 8, + startM: 0, + endH: 16, + endM: 0, + }; + }; + + const openManualEntry = () => { + const prefill = getManualPrefillForSelectedDay(); + setEditMenuOpen(false); + setManualDate(prefill.date); + setManualType(prefill.type); + setManualStartH(prefill.startH); + setManualStartM(prefill.startM); + setManualEndH(prefill.endH); + setManualEndM(prefill.endM); + setPickerKey(k => k + 1); + setManualModalOpen(true); + }; // Push the saved shift to the widget so it updates immediately without opening FlightScreen const pushShiftToWidget = async (date: string, type: 'work' | 'rest', startH?: number, startM?: number, endH?: number, endM?: number) => { @@ -133,10 +206,10 @@ export default function CalendarScreen() { } else { Alert.alert(t('calPermDenied')); } - return; - } - - try { + return; + } + + try { const calendarId = calId ?? await getWritableCalendarId(); if (!calendarId) { Alert.alert('Errore', t('calNoWritableCalendar')); return; } if (!calId) setCalId(calendarId); @@ -150,116 +223,95 @@ export default function CalendarScreen() { endTime: manualType === 'Lavoro' ? `${String(manualEndH).padStart(2, '0')}:${String(manualEndM).padStart(2, '0')}` : undefined, }); - setManualModalOpen(false); - fetchCalendar(true); - pushShiftToWidget(manualDate, shiftType, manualStartH, manualStartM, manualEndH, manualEndM); - 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(); - }); - }; + setManualModalOpen(false); + fetchCalendar(true); + pushShiftToWidget(manualDate, shiftType, manualStartH, manualStartM, manualEndH, manualEndM); + Alert.alert(t('calShiftSaved')); + } catch (e: any) { Alert.alert('Errore', e.message); } + }; + + // Load saved name + useEffect(() => { + AsyncStorage.getItem(STORAGE_KEY).then(n => { if (n) setSavedName(n); }); + }, []); + + useEffect(() => { + const localeConfig = LocaleConfig as unknown as { + locales?: Record; + defaultLocale?: string; + }; + + localeConfig.locales ??= {}; + localeConfig.locales[lang] = { + monthNames: months, + monthNamesShort: months.map(month => month.slice(0, 3)), + dayNames: weekDaysLong, + dayNamesShort: weekDaysShort, + today: lang === 'it' ? 'Oggi' : 'Today', + }; + localeConfig.defaultLocale = lang; + }, [lang, months, weekDaysLong, weekDaysShort]); + + useEffect(() => { + if (!airportLoading) fetchCalendar(); + }, [visibleMonth, airportCode, airportLoading]); + + useEffect(() => { + if (airportLoading || loading) return; + const weekStart = getMonday(fromIsoDate(selectedDay)); + weekStart.setHours(0, 0, 0, 0); + const weekEnd = new Date(weekStart); + weekEnd.setDate(weekEnd.getDate() + 7); + fetchWeatherAndFlights(weekStart, weekEnd, eventsData); + }, [selectedDay, eventsData, airportCode, airportLoading, loading, weatherMap]); + + 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 monthStart = getMonthStart(visibleMonth); + const monthEnd = new Date(visibleMonth.getFullYear(), visibleMonth.getMonth() + 1, 1, 0, 0, 0, 0); + const events = await SystemCalendar.getEventsAsync([cal.id], monthStart, monthEnd); + const localData: Record = {}; + events.forEach(e => { + if (e.title.includes('Lavoro') || e.title.includes('Riposo')) { + const iso = toLocalIso(e.startDate); + if (!localData[iso]) localData[iso] = []; + localData[iso].push({ id: e.id, title: e.title, startDate: e.startDate, endDate: e.endDate }); + } + }); + setEventsData(localData); + setLoading(false); + } catch (e) { if (__DEV__) console.error(e); setLoading(false); } + }; - 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 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); } + 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', iconName: 'weather-sunny' }; + dict[date] = { weatherText: m.text, weatherIconName: m.iconName, flightCount: 0 }; + }); + } + } catch (e) { if (__DEV__) console.warn('[calWeather]', e); } try { const { arrivals, departures } = await fetchAirportScheduleRaw(airportCode); const allF = [...arrivals, ...departures]; @@ -268,14 +320,14 @@ export default function CalendarScreen() { 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); } + 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', weatherIconName: 'cloud-question', flightCount: cnt }; + } + }); + } catch (e) { if (__DEV__) console.warn('[calFlights]', e); } setDailyStats(dict); }; @@ -385,12 +437,12 @@ export default function CalendarScreen() { }); // Push today's shift to widget if it's included in the import - const todayIso = new Date().toISOString().split('T')[0]; - const todayShift = selectedEmployee.shifts.find(s => s.date === todayIso); - if (todayShift) { - const [sh, sm] = (todayShift.start || '00:00').split(':').map(Number); - const [eh, em] = (todayShift.end || '00:00').split(':').map(Number); - pushShiftToWidget(todayIso, todayShift.type, sh, sm, eh, em); + const todayIso = toLocalIso(new Date()); + const todayShift = selectedEmployee.shifts.find(s => s.date === todayIso); + if (todayShift) { + const [sh, sm] = (todayShift.start || '00:00').split(':').map(Number); + const [eh, em] = (todayShift.end || '00:00').split(':').map(Number); + pushShiftToWidget(todayIso, todayShift.type, sh, sm, eh, em); } setImportStep('done'); @@ -407,107 +459,268 @@ export default function CalendarScreen() { } }; - 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 ( - + const monthLabel = visibleMonth.toLocaleDateString(locale, { month: 'long', year: 'numeric' }); + const todayIso = toLocalIso(new Date()); + 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 markedDates = useMemo(() => { + const next: CalendarMarkedDates = {}; + for (const [iso, events] of Object.entries(eventsData)) { + const dots = []; + if (events.some(event => event.title.includes('Lavoro'))) { + dots.push({ key: 'work', color: colors.primary, selectedDotColor: '#fff' }); + } + if (events.some(event => event.title.includes('Riposo'))) { + dots.push({ key: 'rest', color: '#10b981', selectedDotColor: '#fff' }); + } + if (dots.length > 0) next[iso] = { dots }; + } + next[selectedDay] = { + ...(next[selectedDay] ?? {}), + selected: true, + selectedColor: colors.primary, + selectedTextColor: '#fff', + }; + return next; + }, [eventsData, selectedDay, colors.primary]); + const monthHoursSummary = useMemo(() => { + const year = visibleMonth.getFullYear(); + const month = visibleMonth.getMonth(); + const dayEntries = Object.entries(eventsData) + .filter(([iso]) => { + const d = fromIsoDate(iso); + return d.getFullYear() === year && d.getMonth() === month; + }) + .sort((a, b) => a[0].localeCompare(b[0])); + + let totalMinutes = 0; + const workDays: Array<{ iso: string; hours: number }> = []; + for (const [iso, events] of dayEntries) { + const work = events.find(e => e.title.includes('Lavoro')); + if (!work) continue; + const start = new Date(work.startDate).getTime(); + const end = new Date(work.endDate).getTime(); + const minutes = Math.max(0, Math.round((end - start) / 60000)); + totalMinutes += minutes; + workDays.push({ iso, hours: minutes / 60 }); + } + return { totalHours: totalMinutes / 60, shiftsCount: workDays.length, workDays }; + }, [eventsData, visibleMonth]); + + const fmtDate = (iso: string) => { + const [y, m, d] = iso.split('-'); + const dt = new Date(+y, +m - 1, +d); + const dayName = weekDaysShort[dt.getDay()]; + return `${dayName} ${d}/${m}`; + }; + + const handleDayPress = (day: DateData) => { + setSelectedDay(day.dateString); + const nextMonth = new Date(day.year, day.month - 1, 1); + if (!isSameMonth(visibleMonth, day.dateString)) { + setVisibleMonth(nextMonth); + } + }; + + const handleMonthChange = (day: DateData) => { + const nextMonth = new Date(day.year, day.month - 1, 1); + setVisibleMonth(nextMonth); + if (!isSameMonth(nextMonth, selectedDay)) { + setSelectedDay(day.dateString); + } + }; + + const renderCalendarDay = (props: any) => { + const { date, state, marking, onPress } = props as { + date?: DateData; + state?: string; + marking?: CalendarMarkedDates[string] & { dots?: Array<{ key?: string; color: string; selectedDotColor?: string }> }; + onPress?: (value?: DateData) => void; + }; + + if (!date) return ; + + const isSelected = !!marking?.selected; + const isToday = date.dateString === todayIso; + const isInactive = state === 'disabled' || state === 'inactive'; + const dots = marking?.dots ?? []; + + return ( + onPress?.(date)}> + + + {date.day} + + + + {dots.slice(0, 2).map(dot => ( + + ))} + + + ); + }; + + 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')}{'\n'}{selectedDay.split('-').reverse().join('/')} - )} - - )} - - + setEditMenuOpen(true)}> + + {t('calEditBtn')} + + + + + {loading ? ( + + ) : ( + <> + + + {fmtDate(selectedDay)} + + {stats && ( + + + + {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')}{'\n'}{selectedDay.split('-').reverse().join('/')} + )} + + + + ( + + )} + theme={{ + calendarBackground: 'transparent', + monthTextColor: colors.primaryDark, + dayTextColor: colors.text, + textDisabledColor: colors.textMuted, + todayTextColor: colors.primary, + selectedDayTextColor: '#fff', + arrowColor: colors.primary, + textSectionTitleColor: colors.textSub, + textMonthFontSize: 18, + textMonthFontWeight: '800', + textDayHeaderFontSize: 12, + textDayHeaderFontWeight: '700', + textDayFontSize: 15, + }} + style={s.monthCalendar} + headerStyle={s.monthCalendarHeader} + /> + + + + {t('calTypeWork')} + + + + {t('calTypeRest')} + + + + + + {t('calToday')} + + + + {t('calMonthTotalHours')} + {monthHoursSummary.totalHours.toFixed(1)} h + + {t('calMonthShiftsCount').replace('{count}', String(monthHoursSummary.shiftsCount))} + + + + + )} + {/* ─── Edit Menu Modal ─── */} setEditMenuOpen(false)}> @@ -550,11 +763,12 @@ export default function CalendarScreen() { {/* Contenuto scrollabile */} - + {/* Data */} {t('calDataLabel')} {t('calTypeLabel')} - {(['Lavoro', 'Riposo'] as const).map(shiftType => ( - setManualType(shiftType)} - > - {shiftType === 'Lavoro' ? t('calTypeWork') : t('calTypeRest')} - - ))} - + {(['Lavoro', 'Riposo'] as const).map(shiftType => ( + setManualType(shiftType)} + > + + + + {shiftType === 'Lavoro' ? t('calTypeWork') : t('calTypeRest')} + + + + ))} + {/* Orari (solo lavoro) */} {manualType === 'Lavoro' && ( @@ -746,49 +969,80 @@ export default function CalendarScreen() { function makeStyles(c: ThemeColors) { 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: 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' }, + 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' }, + calendarCard: { + backgroundColor: c.card, + borderRadius: 20, + marginHorizontal: 16, + marginTop: 16, + paddingHorizontal: 12, + paddingTop: 8, + paddingBottom: 14, + shadowColor: c.primary, + shadowOpacity: c.isDark ? 0 : 0.08, + shadowRadius: 10, + elevation: c.isDark ? 0 : 4, + borderWidth: c.isDark ? 1 : 0, + borderColor: c.glassBorder, + }, + monthCalendar: { borderRadius: 16 }, + monthCalendarHeader: { paddingBottom: 8, marginBottom: 6 }, + dayCellWrap: { alignItems: 'center', justifyContent: 'center', paddingVertical: 2 }, + dayCellInner: { width: 36, height: 36, borderRadius: 18, alignItems: 'center', justifyContent: 'center', borderWidth: 1.5, borderColor: 'transparent' }, + dayCellInnerSelected: { backgroundColor: c.primary }, + dayCellInnerToday: { borderColor: c.primary, backgroundColor: c.primaryLight }, + dayCellInnerTodaySelected: { borderColor: c.primaryDark }, + dayCellText: { color: c.text, fontSize: 15, fontWeight: '600' }, + dayCellTextInactive: { color: c.textMuted }, + dayCellTextToday: { color: c.primaryDark, fontWeight: '800' }, + dayCellTextSelected: { color: '#fff' }, + dayDotsRow: { minHeight: 10, flexDirection: 'row', alignItems: 'center', justifyContent: 'center', marginTop: 3 }, + dayDot: { width: 5, height: 5, borderRadius: 2.5, marginHorizontal: 1.5 }, + calendarLegend: { flexDirection: 'row', flexWrap: 'wrap', gap: 16, paddingHorizontal: 6, paddingTop: 8 }, + legendItem: { flexDirection: 'row', alignItems: 'center', gap: 8 }, + legendDot: { width: 8, height: 8, borderRadius: 4 }, + legendTodayRing: { width: 12, height: 12, borderRadius: 6, borderWidth: 1.5, borderColor: c.primary, alignItems: 'center', justifyContent: 'center' }, + legendTodayCenter: { width: 4, height: 4, borderRadius: 2, backgroundColor: c.primary }, + legendText: { color: c.textSub, fontSize: 12, fontWeight: '600' }, + calendarSummary: { marginTop: 14, paddingTop: 14, borderTopWidth: 1, borderTopColor: c.border }, + calendarSummaryLabel: { color: c.textSub, fontSize: 12, fontWeight: '700', letterSpacing: 0.8, textTransform: 'uppercase' }, + calendarSummaryValue: { color: c.primary, fontSize: 28, fontWeight: '800', marginTop: 6 }, + calendarSummaryMeta: { color: c.textSub, fontSize: 13, fontWeight: '600', 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, + }, + selectedDayHeader: { marginBottom: 12, paddingRight: 90 }, + selectedDayLabel: { color: c.textSub, fontSize: 12, fontWeight: '700', letterSpacing: 0.8, textTransform: 'uppercase' }, + weatherBadge: { + position: 'absolute', top: 14, right: 14, + flexDirection: 'row', alignItems: 'center', + backgroundColor: c.bg, borderRadius: 10, + paddingHorizontal: 10, paddingVertical: 6, gap: 6, + }, + weatherIcon: { marginRight: 2 }, + 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 + 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' }, + flightBadgeRow: { flexDirection: 'row', alignItems: 'center', gap: 6 }, + flightBadgeText: { color: c.primary, fontWeight: '700', fontSize: 13 }, + restRow: { flexDirection: 'row', alignItems: 'center', marginTop: 10 }, + restIconBox: { width: 48, height: 48, borderRadius: 14, backgroundColor: '#10b98122', alignItems: 'center', justifyContent: 'center', marginRight: 12 }, + 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' }, @@ -817,9 +1071,10 @@ function makeStyles(c: ThemeColors) { // 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' }, - }); -} + manualTimeRow: { flexDirection: 'row', gap: 10, marginBottom: 14 }, + manualTimeInput: { flex: 1, textAlign: 'center' }, + manualTypeBtn: { flex: 1, paddingVertical: 12, borderRadius: 10, borderWidth: 1.5, alignItems: 'center' }, + manualTypeInner: { flexDirection: 'row', alignItems: 'center', gap: 6 }, + }); +} diff --git a/src/screens/FlightScreen.tsx b/src/screens/FlightScreen.tsx index e200b90..09dbe5d 100644 --- a/src/screens/FlightScreen.tsx +++ b/src/screens/FlightScreen.tsx @@ -1,9 +1,10 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { View, Text, StyleSheet, ActivityIndicator, Modal, ScrollView, - FlatList, TouchableOpacity, RefreshControl, Image, Alert, - Animated, PanResponder, NativeModules, Platform, + FlatList, TouchableOpacity, RefreshControl, Image, + Animated, PanResponder, NativeModules, Platform, Switch, } from 'react-native'; +import { Easing } from 'react-native'; import * as Calendar from 'expo-calendar'; import * as Notifications from 'expo-notifications'; import AsyncStorage from '@react-native-async-storage/async-storage'; @@ -13,22 +14,98 @@ import { useAirport } from '../context/AirportContext'; import { getAirlineOps, getAirlineColor, AIRLINE_COLORS, AIRLINE_DISPLAY_NAMES } from '../utils/airlineOps'; import { fetchAirportScheduleRaw } from '../utils/fr24api'; import { fetchStaffMonitorData, normalizeFlightNumber, type StaffMonitorFlight } from '../utils/staffMonitor'; -import { formatAirportHeader, getAirportAirlines } from '../utils/airportSettings'; +import { formatAirportHeader, getAirportAirlines, getStoredAirportAirlines } from '../utils/airportSettings'; import { requestWidgetUpdate } from 'react-native-android-widget'; import { WIDGET_CACHE_KEY, WIDGET_SHIFT_KEY } from '../widgets/widgetTaskHandler'; import type { WidgetData, WidgetFlight, WidgetShiftData } from '../widgets/widgetTaskHandler'; import { ShiftWidget } from '../widgets/ShiftWidget'; import { useLanguage } from '../context/LanguageContext'; import type { TranslationKey } from '../i18n/translations'; +import { dismissPinnedFlightNotification, showOrUpdatePinnedFlightNotification } from '../utils/pinnedFlightOngoingNotification'; const WearDataSender = Platform.OS === 'android' ? NativeModules.WearDataSender : null; const NOTIF_IDS_KEY = 'aerostaff_notif_ids_v1'; const NOTIF_ENABLED_KEY = 'aerostaff_notif_enabled'; +const NOTIF_SETTINGS_KEY = 'aerostaff_notif_settings_v1'; const PINNED_FLIGHT_KEY = 'pinned_flight_v1'; const PINNED_NOTIF_IDS_KEY = 'pinned_notif_ids_v1'; const FLIGHT_FILTER_KEY = 'aerostaff_flight_filter_v1'; const FLIGHTS_CACHE_KEY = 'aerostaff_flights_cache_v2'; +const FLIGHTS_RETENTION_SECONDS = 60 * 60; +const MIN_NOTIF_MINUTES = 1; +const MAX_NOTIF_MINUTES = 90; +type FlightAlertTone = 'success' | 'warning' | 'info'; + +type FlightNotificationSettings = { + onlyTrackedAirlines: boolean; + includeArrivals: boolean; + includeDepartures: boolean; + includeShiftEnd: boolean; + sticky: boolean; + arrivalLeadMinutes: number; + departureLeadMinutes: number; +}; + +const DEFAULT_NOTIFICATION_SETTINGS: FlightNotificationSettings = { + onlyTrackedAirlines: true, + includeArrivals: true, + includeDepartures: false, + includeShiftEnd: true, + sticky: false, + arrivalLeadMinutes: 15, + departureLeadMinutes: 10, +}; + +function normalizeAirlineKey(value: unknown): string { + return typeof value === 'string' + ? value.trim().toLowerCase().replace(/\s+/g, ' ') + : ''; +} + +function sanitizeNotificationSettings(value: unknown): FlightNotificationSettings { + const raw = (value && typeof value === 'object') ? (value as Record) : {}; + const num = (field: string, fallback: number) => { + const v = raw[field]; + if (typeof v !== 'number' || !Number.isFinite(v)) return fallback; + return clamp(Math.round(v), MIN_NOTIF_MINUTES, MAX_NOTIF_MINUTES); + }; + + return { + onlyTrackedAirlines: typeof raw.onlyTrackedAirlines === 'boolean' + ? raw.onlyTrackedAirlines + : DEFAULT_NOTIFICATION_SETTINGS.onlyTrackedAirlines, + includeArrivals: typeof raw.includeArrivals === 'boolean' + ? raw.includeArrivals + : DEFAULT_NOTIFICATION_SETTINGS.includeArrivals, + includeDepartures: typeof raw.includeDepartures === 'boolean' + ? raw.includeDepartures + : DEFAULT_NOTIFICATION_SETTINGS.includeDepartures, + includeShiftEnd: typeof raw.includeShiftEnd === 'boolean' + ? raw.includeShiftEnd + : DEFAULT_NOTIFICATION_SETTINGS.includeShiftEnd, + sticky: typeof raw.sticky === 'boolean' + ? raw.sticky + : DEFAULT_NOTIFICATION_SETTINGS.sticky, + arrivalLeadMinutes: num('arrivalLeadMinutes', DEFAULT_NOTIFICATION_SETTINGS.arrivalLeadMinutes), + departureLeadMinutes: num('departureLeadMinutes', DEFAULT_NOTIFICATION_SETTINGS.departureLeadMinutes), + }; +} + +function shouldNotifyAirline( + item: any, + settings: FlightNotificationSettings, + selectedAirlines: string[], +): boolean { + if (!settings.onlyTrackedAirlines || selectedAirlines.length === 0) { + return true; + } + const airline = normalizeAirlineKey(item?.flight?.airline?.name); + if (!airline) { + return false; + } + return selectedAirlines.some(key => airline.includes(normalizeAirlineKey(key))); +} function flightKey(item: any, tsField: string): string { // Use flight number + scheduled time as a stable key. @@ -46,6 +123,122 @@ function mergeFlights(cached: any[], fresh: any[], tsField: string): any[] { return Array.from(map.values()); } +function pruneExpiredFlights(items: any[], tsField: string, nowSeconds = Date.now() / 1000): any[] { + const cutoff = nowSeconds - FLIGHTS_RETENTION_SECONDS; + return items.filter(item => { + const ts = item.flight?.time?.scheduled?.[tsField]; + if (!ts) return true; + return ts >= cutoff; + }); +} + +function sameAirlineKeys(left: string[], right: string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +const AIRLINE_IATA_CODES: Record = { + 'ryanair': 'FR', + 'easyjet': 'U2', + 'wizz': 'W6', + 'volotea': 'V7', + 'vueling': 'VY', + 'transavia': 'TO', + 'aer lingus': 'EI', + 'british airways': 'BA', + 'sas': 'SK', + 'scandinavian': 'SK', + 'flydubai': 'FZ', + 'aeroitalia': 'XZ', + 'air arabia maroc': '3O', + 'air arabia': 'G9', + 'air dolomiti': 'EN', + 'buzz': 'RR', + 'dhl': 'QY', + 'eurowings': 'EW', + 'ita airways': 'AZ', + 'lufthansa': 'LH', +}; + +const FALLBACK_BRAND_COLORS = [ + '#2563EB', + '#0EA5E9', + '#06B6D4', + '#14B8A6', + '#22C55E', + '#84CC16', + '#F59E0B', + '#F97316', + '#D946EF', + '#8B5CF6', +] as const; + +function stableBrandColor(key: string): string { + let hash = 0; + for (let i = 0; i < key.length; i += 1) { + hash = ((hash << 5) - hash + key.charCodeAt(i)) | 0; + } + return FALLBACK_BRAND_COLORS[Math.abs(hash) % FALLBACK_BRAND_COLORS.length]; +} + +function getAirlineBrandColor(key: string, label: string): string { + const normalized = normalizeAirlineKey(`${key} ${label}`); + for (const [needle, color] of Object.entries(AIRLINE_COLORS)) { + if (normalized.includes(needle)) { + return color; + } + } + return stableBrandColor(normalized || key || label); +} + +function getAirlineIataCode(key: string, label: string): string { + const normalized = normalizeAirlineKey(`${key} ${label}`); + for (const [needle, code] of Object.entries(AIRLINE_IATA_CODES)) { + if (normalized.includes(needle)) { + return code; + } + } + return ''; +} + +function getAirlineMonogram(label: string): string { + const words = label + .split(/[\s._-]+/) + .filter(Boolean); + if (words.length === 0) { + return '??'; + } + return words + .slice(0, 2) + .map(part => part[0] ?? '') + .join('') + .toUpperCase() + .padEnd(2, '?') + .slice(0, 2); +} + +function prettifyAirlineLabel(key: string): string { + return key.replace(/\b\w/g, ch => ch.toUpperCase()); +} + +function hexToRgba(hex: string, alpha: number): string { + const raw = hex.trim().replace('#', ''); + const normalized = raw.length === 3 + ? raw.split('').map(ch => ch + ch).join('') + : raw; + if (!/^[0-9a-fA-F]{6}$/.test(normalized)) { + return `rgba(37,99,235,${alpha})`; + } + const int = parseInt(normalized, 16); + const r = (int >> 16) & 255; + const g = (int >> 8) & 255; + const b = int & 255; + return `rgba(${r},${g},${b},${alpha})`; +} + // Handler: mostra notifiche anche con app aperta (wrapped for Expo Go compat) try { Notifications.setNotificationHandler({ handleNotification: async () => ({ @@ -76,7 +269,37 @@ function LogoPill({ iataCode, airlineName, color }: { iataCode: string; airlineN ); } +function AirlineFilterLogo({ + iataCode, + label, + color, +}: { + iataCode: string; + label: string; + color: string; +}) { + const [err, setErr] = useState(false); + const logoUri = iataCode ? `https://pics.avs.io/160/60/${iataCode.toUpperCase()}.png` : ''; + const monogram = getAirlineMonogram(label); + if (iataCode && !err) { + return ( + + setErr(true)} /> + + ); + } + + return ( + + {monogram} + + ); +} + const SWIPE_THRESHOLD = 80; +const SWIPE_TRIGGER_VELOCITY = 0.5; +const SWIPE_MAX_TRANSLATE = 96; +const SWIPE_DRAG_RESISTANCE = 0.82; function SwipeableFlightCardComponent({ children, isPinned, onToggle, @@ -88,31 +311,55 @@ function SwipeableFlightCardComponent({ const translateX = useRef(new Animated.Value(0)).current; const onToggleRef = useRef(onToggle); onToggleRef.current = onToggle; + const dragScale = useMemo(() => translateX.interpolate({ + inputRange: [-SWIPE_MAX_TRANSLATE, 0], + outputRange: [0.985, 1], + extrapolate: 'clamp', + }), [translateX]); + + const animateBack = useCallback((velocity = 0) => { + Animated.spring(translateX, { + toValue: 0, + velocity, + damping: 20, + stiffness: 185, + mass: 0.9, + useNativeDriver: true, + }).start(); + }, [translateX]); const panResponder = useMemo(() => PanResponder.create({ onMoveShouldSetPanResponder: (_, g) => Math.abs(g.dx) > 15 && Math.abs(g.dx) > Math.abs(g.dy) * 1.5, onPanResponderMove: (_, g) => { - if (g.dx < 0) translateX.setValue(g.dx); + const nextTranslate = g.dx < 0 + ? Math.max(g.dx * SWIPE_DRAG_RESISTANCE, -SWIPE_MAX_TRANSLATE) + : g.dx * 0.08; + translateX.setValue(nextTranslate); }, onPanResponderRelease: (_, g) => { - if (g.dx < -SWIPE_THRESHOLD) { - Animated.timing(translateX, { toValue: -SWIPE_THRESHOLD, duration: 100, useNativeDriver: true }).start(() => { + if (g.dx < -SWIPE_THRESHOLD || g.vx < -SWIPE_TRIGGER_VELOCITY) { + Animated.timing(translateX, { + toValue: -SWIPE_MAX_TRANSLATE, + duration: 170, + easing: Easing.out(Easing.cubic), + useNativeDriver: true, + }).start(() => { onToggleRef.current(); - Animated.spring(translateX, { toValue: 0, useNativeDriver: true, tension: 120, friction: 10 }).start(); + animateBack(); }); } else { - Animated.spring(translateX, { toValue: 0, useNativeDriver: true, tension: 120, friction: 10 }).start(); + animateBack(g.vx); } }, onPanResponderTerminate: () => { - Animated.spring(translateX, { toValue: 0, useNativeDriver: true }).start(); + animateBack(); }, - }), []); + }), [animateBack, translateX]); return ( - + {children} @@ -171,6 +418,8 @@ function FlightRowComponent({ item, activeTab, userShift, pinnedFlightId, onPin, const reg = item.flight?.aircraft?.registration; const inboundTs = reg ? inboundArrivals[reg] : undefined; const gateOpenFromInbound = activeTab === 'departures' && ts && inboundTs ? inboundTs : undefined; + const pulseAnim = useRef(new Animated.Value(0)).current; + const [nowTs, setNowTs] = useState(() => Date.now() / 1000); const flightId = item.flight?.identification?.number?.default || null; const isPinned = flightId !== null && flightId === pinnedFlightId; @@ -182,6 +431,90 @@ function FlightRowComponent({ item, activeTab, userShift, pinnedFlightId, onPin, smPool.find(sm => sm.flightNumber === normFn) ?? smPool.find(sm => normalizeForMatching(sm.flightNumber) === normFnStripped); + const arrivalProgress = activeTab === 'arrivals' && ts ? (() => { + const scheduledDep = item.flight?.time?.scheduled?.departure; + const estimatedDep = item.flight?.time?.estimated?.departure; + const realDep = item.flight?.time?.real?.departure; + const estimatedArr = item.flight?.time?.estimated?.arrival; + const realArr = item.flight?.time?.real?.arrival; + const startTs = realDep || estimatedDep || scheduledDep; + const endTs = realArr || estimatedArr || ts; + if (!startTs || !endTs || endTs <= startTs) return null; + + const delayMin = Math.round((endTs - ts) / 60); + const progressColor = realArr ? '#10B981' + : delayMin > 20 ? '#EF4444' + : delayMin > 5 ? '#F59E0B' + : colors.primary; + + return { + startTs, + endTs, + progress: realArr ? 1 : clamp((Date.now() / 1000 - startTs) / (endTs - startTs), 0, 1), + departureColor: realDep ? colors.primary : '#6B7280', + arrivalColor: progressColor, + planeColor: progressColor, + }; + })() : null; + + const checkinShouldPulse = activeTab === 'departures' && ts && ops ? (() => { + const ciOpenTs = ts - ops.checkInOpen * 60; + const ciCloseTs = ts - ops.checkInClose * 60; + return (nowTs >= ciOpenTs - 10 * 60 && nowTs < ciOpenTs) + || (nowTs >= ciCloseTs - 10 * 60 && nowTs < ciCloseTs); + })() : false; + const gateShouldPulse = activeTab === 'departures' && ts && ops ? (() => { + const gateOpenTs = gateOpenFromInbound ?? (ts - ops.gateOpen * 60); + const gateCloseTs = ts - ops.gateClose * 60; + return (nowTs >= gateOpenTs - 5 * 60 && nowTs < gateOpenTs) + || (nowTs >= gateCloseTs - 5 * 60 && nowTs < gateCloseTs); + })() : false; + + useEffect(() => { + if (!checkinShouldPulse && !gateShouldPulse) { + pulseAnim.stopAnimation(); + pulseAnim.setValue(0); + return; + } + + const loop = Animated.loop( + Animated.sequence([ + Animated.timing(pulseAnim, { toValue: 1, duration: 520, useNativeDriver: false }), + Animated.timing(pulseAnim, { toValue: 0, duration: 520, useNativeDriver: false }), + ]), + ); + loop.start(); + return () => loop.stop(); + }, [checkinShouldPulse, gateShouldPulse, pulseAnim]); + + const checkinPulseStyle = checkinShouldPulse + ? { + borderWidth: 1.5, + borderColor: '#F59E0B', + backgroundColor: pulseAnim.interpolate({ + inputRange: [0, 1], + outputRange: [colors.primaryLight, 'rgba(245, 158, 11, 0.26)'], + }), + } + : null; + const gatePulseStyle = gateShouldPulse + ? { + borderWidth: 1.5, + borderColor: '#F97316', + backgroundColor: pulseAnim.interpolate({ + inputRange: [0, 1], + outputRange: [colors.primaryLight, 'rgba(249, 115, 22, 0.28)'], + }), + } + : null; + + useEffect(() => { + const interval = setInterval(() => { + setNowTs(Date.now() / 1000); + }, 15000); + return () => clearInterval(interval); + }, []); + return ( {activeTab === 'departures' && ops ? ( - + {t('flightCheckin')} {fmt(ops.checkInOpen)} – {fmt(ops.checkInClose)} - - + + {t('flightGate')} @@ -222,7 +555,7 @@ function FlightRowComponent({ item, activeTab, userShift, pinnedFlightId, onPin, {gateOpenFromInbound ? fmtTs(gateOpenFromInbound) : fmt(ops.gateOpen)} – {fmt(ops.gateClose)} - + ) : activeTab === 'arrivals' && ts ? (() => { const realDep = item.flight?.time?.real?.departure; @@ -262,6 +595,48 @@ function FlightRowComponent({ item, activeTab, userShift, pinnedFlightId, onPin, })() : ( {`Da: ${originDest}`} )} + {arrivalProgress && ( + + + + + {fmtTs(arrivalProgress.startTs)} + + + + {fmtTs(arrivalProgress.endTs)} + + + + + + + + + + + + + + )} {/* Status pill β€” own row, right-aligned */} {activeTab === 'arrivals' && ts ? (() => { const rArr = item.flight?.time?.real?.arrival; @@ -323,53 +698,95 @@ async function cancelPreviousNotifications() { } async function scheduleShiftNotifications( - shiftFlights: any[], + shiftArrivals: any[], + shiftDepartures: any[], shiftEnd: number, locale: string, + settings: FlightNotificationSettings, + selectedAirlines: string[], ): Promise { await cancelPreviousNotifications(); const now = Date.now() / 1000; const newIds: string[] = []; + const canNotify = (item: any) => shouldNotifyAirline(item, settings, selectedAirlines); + + if (settings.includeArrivals) { + for (const item of shiftArrivals) { + if (!canNotify(item)) continue; + const ts: number | undefined = item.flight?.time?.scheduled?.arrival; + if (!ts) continue; + const secondsUntilNotify = ts - settings.arrivalLeadMinutes * 60 - now; + if (secondsUntilNotify <= 0) continue; + + const flightNumber = item.flight?.identification?.number?.default || 'N/A'; + const airline = item.flight?.airline?.name || 'Sconosciuta'; + const origin = item.flight?.airport?.origin?.name + || item.flight?.airport?.origin?.code?.iata + || 'N/A'; + const arrivalTime = new Date(ts * 1000).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }); - for (const item of shiftFlights) { - const ts: number | undefined = item.flight?.time?.scheduled?.arrival; - if (!ts) continue; - const secondsUntilNotify = ts - 15 * 60 - now; // 15 min prima - if (secondsUntilNotify <= 0) continue; // giΓ  passato - - const flightNumber = item.flight?.identification?.number?.default || 'N/A'; - const airline = item.flight?.airline?.name || 'Sconosciuta'; - const origin = item.flight?.airport?.origin?.name - || item.flight?.airport?.origin?.code?.iata - || 'N/A'; - const arrivalTime = new Date(ts * 1000).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }); - - const id = await Notifications.scheduleNotificationAsync({ - content: { - title: `✈️ Arrivo tra 15 min β€” ${flightNumber}`, - body: `${airline} da ${origin} Β· atterraggio alle ${arrivalTime}`, - sound: true, - data: { flightNumber, ts }, - }, - trigger: { type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL, seconds: Math.round(secondsUntilNotify), repeats: false }, - }); - newIds.push(id); + const id = await Notifications.scheduleNotificationAsync({ + content: { + title: `Arrivo tra ${settings.arrivalLeadMinutes} min - ${flightNumber}`, + body: `${airline} da ${origin} Β· atterraggio alle ${arrivalTime}`, + sound: true, + sticky: settings.sticky, + autoDismiss: !settings.sticky, + data: { flightNumber, ts, type: 'arrival_shift' }, + }, + trigger: { type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL, seconds: Math.round(secondsUntilNotify), repeats: false }, + }); + newIds.push(id); + } } - // Notifica fine turno - const secondsUntilEnd = shiftEnd - now; - if (secondsUntilEnd > 0) { - const endTime = new Date(shiftEnd * 1000).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }); - const endId = await Notifications.scheduleNotificationAsync({ - content: { - title: '🏁 Turno terminato', - body: `Buon lavoro! Il tuo turno delle ${endTime} Γ¨ concluso.`, - sound: true, - data: { type: 'shift_end' }, - }, - trigger: { type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL, seconds: Math.round(secondsUntilEnd), repeats: false }, - }); - newIds.push(endId); + if (settings.includeDepartures) { + for (const item of shiftDepartures) { + if (!canNotify(item)) continue; + const ts: number | undefined = item.flight?.time?.scheduled?.departure; + if (!ts) continue; + const secondsUntilNotify = ts - settings.departureLeadMinutes * 60 - now; + if (secondsUntilNotify <= 0) continue; + + const flightNumber = item.flight?.identification?.number?.default || 'N/A'; + const airline = item.flight?.airline?.name || 'Sconosciuta'; + const destination = item.flight?.airport?.destination?.name + || item.flight?.airport?.destination?.code?.iata + || 'N/A'; + const departureTime = new Date(ts * 1000).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }); + + const id = await Notifications.scheduleNotificationAsync({ + content: { + title: `Partenza tra ${settings.departureLeadMinutes} min - ${flightNumber}`, + body: `${airline} β†’ ${destination} Β· decollo alle ${departureTime}`, + sound: true, + sticky: settings.sticky, + autoDismiss: !settings.sticky, + data: { flightNumber, ts, type: 'departure_shift' }, + }, + trigger: { type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL, seconds: Math.round(secondsUntilNotify), repeats: false }, + }); + newIds.push(id); + } + } + + if (settings.includeShiftEnd) { + const secondsUntilEnd = shiftEnd - now; + if (secondsUntilEnd > 0) { + const endTime = new Date(shiftEnd * 1000).toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit' }); + const endId = await Notifications.scheduleNotificationAsync({ + content: { + title: 'Turno terminato', + body: `Buon lavoro! Il tuo turno delle ${endTime} Γ¨ concluso.`, + sound: true, + sticky: settings.sticky, + autoDismiss: !settings.sticky, + data: { type: 'shift_end' }, + }, + trigger: { type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL, seconds: Math.round(secondsUntilEnd), repeats: false }, + }); + newIds.push(endId); + } } await AsyncStorage.setItem(NOTIF_IDS_KEY, JSON.stringify(newIds)); @@ -384,7 +801,12 @@ async function cancelPinnedNotifications() { await AsyncStorage.removeItem(PINNED_NOTIF_IDS_KEY); } -async function schedulePinnedNotifications(item: any, tab: 'arrivals' | 'departures', locale: string): Promise { +async function schedulePinnedNotifications( + item: any, + tab: 'arrivals' | 'departures', + locale: string, + settings: FlightNotificationSettings, +): Promise { await cancelPinnedNotifications(); const now = Date.now() / 1000; const ids: string[] = []; @@ -393,17 +815,19 @@ async function schedulePinnedNotifications(item: any, tab: 'arrivals' | 'departu const airline = item.flight?.airline?.name || 'Sconosciuta'; if (tab === 'arrivals') { - const ts = item.flight?.time?.scheduled?.arrival; + const ts: number | undefined = 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(locale, { hour: '2-digit', minute: '2-digit' }); - const secsUntil = ts - 15 * 60 - now; + const secsUntil = ts - settings.arrivalLeadMinutes * 60 - now; if (secsUntil > 0) { const id = await Notifications.scheduleNotificationAsync({ content: { - title: `πŸ“Œ Arrivo tra 15 min β€” ${flightNumber}`, + title: `Arrivo tra ${settings.arrivalLeadMinutes} min - ${flightNumber}`, body: `${airline} da ${origin} Β· atterraggio alle ${arrTime}`, sound: true, + sticky: settings.sticky, + autoDismiss: !settings.sticky, data: { flightNumber, ts, pinned: true }, }, trigger: { type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL, seconds: Math.round(secsUntil), repeats: false }, @@ -418,10 +842,14 @@ async function schedulePinnedNotifications(item: any, tab: 'arrivals' | 'departu 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}` }, + { 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: settings.departureLeadMinutes, + title: `Partenza tra ${settings.departureLeadMinutes} min - ${flightNumber}`, + body: `${airline} β†’ ${dest} Β· partenza alle ${depTime}`, + }, ]; for (const phase of phases) { @@ -432,6 +860,8 @@ async function schedulePinnedNotifications(item: any, tab: 'arrivals' | 'departu title: phase.title, body: phase.body, sound: true, + sticky: settings.sticky, + autoDismiss: !settings.sticky, data: { flightNumber, ts, pinned: true }, }, trigger: { type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL, seconds: Math.round(secsUntil), repeats: false }, @@ -446,10 +876,21 @@ async function schedulePinnedNotifications(item: any, tab: 'arrivals' | 'departu } // ─── Screen ──────────────────────────────────────────────────────────────────── -export default function FlightScreen() { +type FlightScreenProps = { + openNotifSettingsSignal?: number; +}; + +export default function FlightScreen({ openNotifSettingsSignal = 0 }: FlightScreenProps) { const { colors } = useAppTheme(); const { t, locale } = useLanguage(); - const { airport, airportCode, isLoading: airportLoading } = useAirport(); + const { + airport, + airportCode, + isLoading: airportLoading, + activeProfile, + activeProfileId, + setSelectedAirlines: persistSelectedAirlines, + } = useAirport(); const s = useMemo(() => makeStyles(colors), [colors]); const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); @@ -463,15 +904,54 @@ export default function FlightScreen() { const [pinnedFlightId, setPinnedFlightId] = useState(null); const [inboundArrivals, setInboundArrivals] = useState>({}); const [filterMenuVisible, setFilterMenuVisible] = useState(false); + const [notifSettingsVisible, setNotifSettingsVisible] = useState(false); + const [notifDialog, setNotifDialog] = useState<{ title: string; message: string; tone: FlightAlertTone } | null>(null); const [allArrivalsFull, setAllArrivalsFull] = useState([]); const [allDeparturesFull, setAllDeparturesFull] = useState([]); const [airportAirlines, setAirportAirlines] = useState([]); const [selectedAirlines, setSelectedAirlines] = useState([]); const [staffMonitorDeps, setStaffMonitorDeps] = useState([]); const [staffMonitorArrs, setStaffMonitorArrs] = useState([]); + const [notifSettings, setNotifSettings] = useState(DEFAULT_NOTIFICATION_SETTINGS); + const lastOpenNotifSettingsSignalRef = useRef(openNotifSettingsSignal); + const applySelectedAirlines = useCallback((next: string[]) => { + setSelectedAirlines(next); + persistSelectedAirlines(next).catch(() => {}); + }, [persistSelectedAirlines]); + const airportAirlinesRef = useRef([]); + const selectedAirlinesRef = useRef([]); + const notifSettingsRef = useRef(DEFAULT_NOTIFICATION_SETTINGS); + const selectedAirlinesNotifSignatureRef = useRef(''); + + useEffect(() => { + airportAirlinesRef.current = airportAirlines; + }, [airportAirlines]); + + useEffect(() => { + selectedAirlinesRef.current = selectedAirlines; + }, [selectedAirlines]); + + useEffect(() => { + notifSettingsRef.current = notifSettings; + }, [notifSettings]); + + useEffect(() => { + if (openNotifSettingsSignal === lastOpenNotifSettingsSignalRef.current) { + return; + } + lastOpenNotifSettingsSignalRef.current = openNotifSettingsSignal; + setNotifSettingsVisible(true); + }, [openNotifSettingsSignal]); useEffect(() => { AsyncStorage.getItem(NOTIF_ENABLED_KEY).then(v => setNotifsEnabled(v === 'true')); + AsyncStorage.getItem(NOTIF_SETTINGS_KEY).then(raw => { + if (!raw) return; + try { + const next = sanitizeNotificationSettings(JSON.parse(raw)); + setNotifSettings(next); + } catch {} + }); // Carica voli accumulati oggi cosΓ¬ sono visibili prima del primo fetch const today = new Date().toISOString().split('T')[0]; AsyncStorage.getItem(FLIGHTS_CACHE_KEY).then(raw => { @@ -488,18 +968,44 @@ export default function FlightScreen() { // Carica lista compagnie per aeroporto + selezione salvata useEffect(() => { - const airlines = getAirportAirlines(airportCode); - setAirportAirlines(airlines); - AsyncStorage.getItem(FLIGHT_FILTER_KEY).then(raw => { - try { - const saved: string[] = JSON.parse(raw ?? '[]'); - const valid = saved.filter(k => airlines.includes(k)); - setSelectedAirlines(valid.length > 0 ? valid : [...airlines]); - } catch { - setSelectedAirlines([...airlines]); + let active = true; + + getStoredAirportAirlines(airportCode).then(airlines => { + if (!active) { + return; + } + + setAirportAirlines(airlines); + const saved = activeProfile?.airportCode === airportCode ? activeProfile.airlines : []; + const valid = saved.filter(key => airlines.includes(key)); + + if (saved.length === 0 && activeProfile?.airportCode === airportCode) { + setSelectedAirlines([]); + return; + } + + setSelectedAirlines(valid.length > 0 ? valid : [...airlines]); + }).catch(() => { + if (!active) { + return; + } + + const airlines = getAirportAirlines(airportCode); + setAirportAirlines(airlines); + const saved = activeProfile?.airportCode === airportCode ? activeProfile.airlines : []; + if (saved.length === 0 && activeProfile?.airportCode === airportCode) { + setSelectedAirlines([]); + return; } + + const valid = saved.filter(key => airlines.includes(key)); + setSelectedAirlines(valid.length > 0 ? valid : [...airlines]); }); - }, [airportCode]); + + return () => { + active = false; + }; + }, [activeProfile, activeProfileId, airportCode]); const fetchAll = useCallback(async () => { if (airportLoading) return; @@ -511,22 +1017,39 @@ export default function FlightScreen() { departures: fetchedDepartures, arrivals: fetchedArrivals, } = await fetchAirportScheduleRaw(airportCode); - // Accumula voli: fonde i dati freschi con quelli giΓ  visti oggi - // cosΓ¬ i voli rimossi da FR24 dopo la partenza restano visibili fino a mezzanotte - const today = new Date().toISOString().split('T')[0]; + const nextAirportAirlines = getAirportAirlines(airportCode); + setAirportAirlines(nextAirportAirlines); + + const savedProfileAirlines = activeProfile?.airportCode === airportCode ? activeProfile.airlines : []; + const previousAirportAirlines = airportAirlinesRef.current; + const previousSelectedAirlines = selectedAirlinesRef.current; + const hadAllPreviouslySelected = + previousAirportAirlines.length > 0 && + previousAirportAirlines.every(key => previousSelectedAirlines.includes(key)); + + if (savedProfileAirlines.length === 0) { + if (previousSelectedAirlines.length > 0) { + applySelectedAirlines([]); + } + } else if (hadAllPreviouslySelected && !sameAirlineKeys(savedProfileAirlines, nextAirportAirlines)) { + applySelectedAirlines(nextAirportAirlines); + } + // Accumula voli: fonde i dati freschi con quelli in cache e conserva solo + // i voli non piΓΉ vecchi di 1 ora dall'orario schedulato. let cachedArrs: any[] = [], cachedDeps: any[] = []; try { const raw = await AsyncStorage.getItem(FLIGHTS_CACHE_KEY); if (raw) { const cache = JSON.parse(raw); - if (cache.date === today) { cachedArrs = cache.arrivals ?? []; cachedDeps = cache.departures ?? []; } + cachedArrs = Array.isArray(cache.arrivals) ? cache.arrivals : []; + cachedDeps = Array.isArray(cache.departures) ? cache.departures : []; } } catch {} - const mergedArrs = mergeFlights(cachedArrs, allArrivals, 'arrival'); - const mergedDeps = mergeFlights(cachedDeps, allDepartures, 'departure'); + const mergedArrs = pruneExpiredFlights(mergeFlights(cachedArrs, allArrivals, 'arrival'), 'arrival'); + const mergedDeps = pruneExpiredFlights(mergeFlights(cachedDeps, allDepartures, 'departure'), 'departure'); setAllArrivalsFull(mergedArrs); setAllDeparturesFull(mergedDeps); - AsyncStorage.setItem(FLIGHTS_CACHE_KEY, JSON.stringify({ date: today, arrivals: mergedArrs, departures: mergedDeps })).catch(() => {}); + AsyncStorage.setItem(FLIGHTS_CACHE_KEY, JSON.stringify({ arrivals: mergedArrs, departures: mergedDeps })).catch(() => {}); // Build inbound arrival map: registration β†’ best known arrival timestamp const inboundMap: Record = {}; @@ -544,6 +1067,7 @@ export default function FlightScreen() { setDepartures(fetchedDepartures); // Auto-clear expired pinned flight or stale data from another airport + const notificationsEnabledNow = (await AsyncStorage.getItem(NOTIF_ENABLED_KEY)) === 'true'; const pinnedRaw = await AsyncStorage.getItem(PINNED_FLIGHT_KEY); if (pinnedRaw) { try { @@ -558,7 +1082,16 @@ export default function FlightScreen() { if ((pinTs && pinTs < Date.now() / 1000) || !stillPresent) { await AsyncStorage.removeItem(PINNED_FLIGHT_KEY); await cancelPinnedNotifications(); + await dismissPinnedFlightNotification(); setPinnedFlightId(null); + } else if (stillPresent && pinId && notificationsEnabledNow) { + const updated = pool.find(item => item.flight?.identification?.number?.default === pinId); + if (updated) { + await showOrUpdatePinnedFlightNotification(updated, pinTab, notifSettingsRef.current.sticky); + } + } else if (!notificationsEnabledNow) { + await cancelPinnedNotifications(); + await dismissPinnedFlightNotification(); } } catch {} } @@ -672,20 +1205,30 @@ export default function FlightScreen() { } catch {} // 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 => { + if (notificationsEnabledNow && shiftToday) { + const shiftArrivals = fetchedArrivals.filter(item => { const ts = item.flight?.time?.scheduled?.arrival; - return ts && ts >= shiftToday!.start && ts <= shiftToday!.end; + return ts && ts >= shiftToday.start && ts <= shiftToday.end; + }); + const shiftDepartures = fetchedDepartures.filter(item => { + const ts = item.flight?.time?.scheduled?.departure; + return ts && ts >= shiftToday.start && ts <= shiftToday.end; }); - const count = await scheduleShiftNotifications(shiftFlights, shiftToday!.end, locale); + const count = await scheduleShiftNotifications( + shiftArrivals, + shiftDepartures, + shiftToday.end, + locale, + notifSettingsRef.current, + selectedAirlinesRef.current, + ); setScheduledCount(count); } else { await cancelPreviousNotifications(); setScheduledCount(0); } } catch (e) { if (__DEV__) console.error('[fetchAll]', e); } finally { setLoading(false); setRefreshing(false); } - }, [airportCode, airportLoading]); + }, [activeProfile, airportCode, airportLoading, applySelectedAirlines]); useEffect(() => { if (airportLoading) return; @@ -733,43 +1276,121 @@ export default function FlightScreen() { return () => clearInterval(iv); }, []); - // Toggle notifiche - const toggleNotifications = useCallback(async () => { - const { status } = await Notifications.requestPermissionsAsync(); - if (status !== 'granted') { - Alert.alert(t('flightNotifPermDenied'), t('flightNotifPermMsg')); - return; + const showNotifDialog = useCallback((title: string, message: string, tone: FlightAlertTone) => { + setNotifDialog({ title, message, tone }); + }, []); + + const scheduleNotificationsForCurrentShift = useCallback(async ( + settings: FlightNotificationSettings = notifSettingsRef.current, + ): Promise => { + if (!shifts.today) { + await cancelPreviousNotifications(); + setScheduledCount(0); + return 0; } - const next = !notifsEnabled; - setNotifsEnabled(next); - await AsyncStorage.setItem(NOTIF_ENABLED_KEY, String(next)); + const shiftArrivals = arrivals.filter(item => { + const ts = item.flight?.time?.scheduled?.arrival; + return ts && ts >= shifts.today!.start && ts <= shifts.today!.end; + }); + const shiftDepartures = departures.filter(item => { + const ts = item.flight?.time?.scheduled?.departure; + return ts && ts >= shifts.today!.start && ts <= shifts.today!.end; + }); + const count = await scheduleShiftNotifications( + shiftArrivals, + shiftDepartures, + shifts.today.end, + locale, + settings, + selectedAirlinesRef.current, + ); + setScheduledCount(count); + return count; + }, [arrivals, departures, locale, shifts.today]); + + const setNotificationsEnabled = useCallback(async (next: boolean) => { if (!next) { + setNotifsEnabled(false); + await AsyncStorage.setItem(NOTIF_ENABLED_KEY, 'false'); await cancelPreviousNotifications(); + await cancelPinnedNotifications(); + await dismissPinnedFlightNotification(); setScheduledCount(0); return; } - // Schedula subito con i dati giΓ  caricati (turno di oggi) - if (shifts.today) { - const shiftFlights = arrivals.filter(item => { - 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, locale); - setScheduledCount(count); - Alert.alert( - t('flightNotifEnabled'), - count > 0 - ? `${t('flightNotifMsg1').replace('{count}', String(count))}` - : t('flightNotifMsg0'), - ); - } else { - Alert.alert(t('flightNoShift'), t('flightNoShiftMsg')); + const { status } = await Notifications.requestPermissionsAsync(); + if (status !== 'granted') { + showNotifDialog(t('flightNotifPermDenied'), t('flightNotifPermMsg'), 'warning'); + return; + } + + if (!shifts.today) { + showNotifDialog(t('flightNoShift'), t('flightNoShiftMsg'), 'info'); setNotifsEnabled(false); await AsyncStorage.setItem(NOTIF_ENABLED_KEY, 'false'); + await cancelPreviousNotifications(); + setScheduledCount(0); + return; + } + + setNotifsEnabled(true); + await AsyncStorage.setItem(NOTIF_ENABLED_KEY, 'true'); + const pinnedRaw = await AsyncStorage.getItem(PINNED_FLIGHT_KEY); + if (pinnedRaw) { + try { + const pinned = JSON.parse(pinnedRaw); + const pinTab = pinned._pinTab || 'departures'; + await schedulePinnedNotifications(pinned, pinTab, locale, notifSettingsRef.current); + await showOrUpdatePinnedFlightNotification(pinned, pinTab, notifSettingsRef.current.sticky); + } catch {} + } + const count = await scheduleNotificationsForCurrentShift(); + showNotifDialog( + t('flightNotifEnabled'), + count > 0 + ? t('flightNotifMsg1').replace('{count}', String(count)) + : t('flightNotifMsg0'), + 'success', + ); + }, [scheduleNotificationsForCurrentShift, shifts.today, showNotifDialog, t]); + + const persistNotificationSettings = useCallback(async (next: FlightNotificationSettings) => { + setNotifSettings(next); + await AsyncStorage.setItem(NOTIF_SETTINGS_KEY, JSON.stringify(next)); + }, []); + + const updateNotificationSettings = useCallback(async ( + patch: Partial, + ) => { + const next = sanitizeNotificationSettings({ ...notifSettingsRef.current, ...patch }); + await persistNotificationSettings(next); + + if (notifsEnabled && pinnedFlightId) { + const pinnedRaw = await AsyncStorage.getItem(PINNED_FLIGHT_KEY); + if (pinnedRaw) { + try { + const pinned = JSON.parse(pinnedRaw); + const pinTab = pinned._pinTab || 'departures'; + await schedulePinnedNotifications(pinned, pinTab, locale, next); + await showOrUpdatePinnedFlightNotification(pinned, pinTab, next.sticky); + } catch {} + } } - }, [notifsEnabled, shifts, arrivals]); + + if (notifsEnabled) { + await scheduleNotificationsForCurrentShift(next); + } + }, [locale, notifsEnabled, persistNotificationSettings, pinnedFlightId, scheduleNotificationsForCurrentShift]); + + useEffect(() => { + const signature = selectedAirlines.join('|'); + const changed = signature !== selectedAirlinesNotifSignatureRef.current; + selectedAirlinesNotifSignatureRef.current = signature; + if (!changed || !notifsEnabled) return; + scheduleNotificationsForCurrentShift().catch(() => {}); + }, [notifsEnabled, scheduleNotificationsForCurrentShift, selectedAirlines]); const pinFlight = useCallback(async (item: any) => { try { @@ -778,7 +1399,12 @@ 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, locale); } catch (e) { if (__DEV__) console.warn('[pinnedNotif]', e); } + if (notifsEnabled) { + try { await schedulePinnedNotifications(item, tab, locale, notifSettingsRef.current); } catch (e) { if (__DEV__) console.warn('[pinnedNotif]', e); } + await showOrUpdatePinnedFlightNotification(item, tab, notifSettingsRef.current.sticky); + } else { + await dismissPinnedFlightNotification(); + } // Send to watch if (WearDataSender) { const payload = JSON.stringify({ @@ -800,12 +1426,13 @@ export default function FlightScreen() { WearDataSender.sendPinnedFlight(payload); } } catch {} - }, [activeTab, inboundArrivals]); + }, [activeTab, inboundArrivals, locale, notifsEnabled]); const unpinFlight = useCallback(async () => { try { await AsyncStorage.removeItem(PINNED_FLIGHT_KEY); try { await cancelPinnedNotifications(); } catch (e) { if (__DEV__) console.warn('[cancelPinNotif]', e); } + await dismissPinnedFlightNotification(); setPinnedFlightId(null); if (WearDataSender) WearDataSender.clearPinnedFlight(); } catch (e) { if (__DEV__) console.error('[unpin]', e); } @@ -851,6 +1478,9 @@ export default function FlightScreen() { t={t} /> ), [activeTab, userShift, s, pinnedFlightId, pinFlight, unpinFlight, inboundArrivals, colors, staffMonitorDeps, staffMonitorArrs, locale, t]); + const notifSummary = scheduledCount > 0 + ? t('flightNotifMsg1').replace('{count}', String(scheduledCount)) + : t('flightNotifMsg0'); return ( @@ -871,10 +1501,10 @@ export default function FlightScreen() { setNotifSettingsVisible(true)} activeOpacity={0.8} accessible - accessibilityLabel={notifsEnabled ? 'Disattiva notifiche voli' : 'Attiva notifiche voli'} + accessibilityLabel={t('flightNotifSettingsTitle')} accessibilityRole="button" > { const next = allSelected ? [] : [...airportAirlines]; - setSelectedAirlines(next); - AsyncStorage.setItem(FLIGHT_FILTER_KEY, JSON.stringify(next)); + applySelectedAirlines(next); }} > @@ -957,27 +1586,44 @@ export default function FlightScreen() { {airportAirlines.map(key => { const checked = selectedAirlines.includes(key); - const dot = AIRLINE_COLORS[key] ?? '#2563EB'; - const label = AIRLINE_DISPLAY_NAMES[key] ?? key; + const label = AIRLINE_DISPLAY_NAMES[key] ?? prettifyAirlineLabel(key); + const brandColor = getAirlineBrandColor(key, label); + const iataCode = getAirlineIataCode(key, label); + const activeBg = hexToRgba(brandColor, colors.isDark ? 0.24 : 0.18); + const inactiveBg = colors.isDark ? 'rgba(2,6,18,0.92)' : 'rgba(255,255,255,0.92)'; return ( { const next = checked ? selectedAirlines.filter(k => k !== key) : [...selectedAirlines, key]; - setSelectedAirlines(next); - AsyncStorage.setItem(FLIGHT_FILTER_KEY, JSON.stringify(next)); + applySelectedAirlines(next); }} > - - {label} + + + {label} + + {iataCode ? `IATA ${iataCode}` : key} + + + + + ); @@ -986,11 +1632,205 @@ export default function FlightScreen() { + + setNotifSettingsVisible(false)} + > + setNotifSettingsVisible(false)} + > + true}> + + {t('flightNotifSettingsTitle')} + {t('flightNotifSettingsSub')} + + + + + {notifsEnabled ? t('flightNotifAccessDisable') : t('flightNotifAccessEnable')} + {notifSummary} + + { setNotificationsEnabled(value).catch(() => {}); }} + trackColor={{ false: '#94A3B8', true: colors.primary }} + thumbColor="#fff" + /> + + + + + + + {t('flightNotifOnlyTracked')} + {t('flightNotifOnlyTrackedSub')} + + { updateNotificationSettings({ onlyTrackedAirlines: value }).catch(() => {}); }} + trackColor={{ false: '#94A3B8', true: colors.primary }} + thumbColor="#fff" + /> + + + + + {t('flightNotifArrivalsToggle')} + {t('flightNotifArrivalsToggleSub')} + + { updateNotificationSettings({ includeArrivals: value }).catch(() => {}); }} + trackColor={{ false: '#94A3B8', true: colors.primary }} + thumbColor="#fff" + /> + + + + + {t('flightNotifDeparturesToggle')} + {t('flightNotifDeparturesToggleSub')} + + { updateNotificationSettings({ includeDepartures: value }).catch(() => {}); }} + trackColor={{ false: '#94A3B8', true: colors.primary }} + thumbColor="#fff" + /> + + + + + {t('flightNotifShiftEndToggle')} + {t('flightNotifShiftEndToggleSub')} + + { updateNotificationSettings({ includeShiftEnd: value }).catch(() => {}); }} + trackColor={{ false: '#94A3B8', true: colors.primary }} + thumbColor="#fff" + /> + + + + + {t('flightNotifStickyToggle')} + {t('flightNotifStickyToggleSub')} + + { updateNotificationSettings({ sticky: value }).catch(() => {}); }} + trackColor={{ false: '#94A3B8', true: colors.primary }} + thumbColor="#fff" + /> + + + + + + {t('flightNotifArrivalLead')} + + updateNotificationSettings({ + arrivalLeadMinutes: clamp(notifSettings.arrivalLeadMinutes - 1, MIN_NOTIF_MINUTES, MAX_NOTIF_MINUTES), + }).catch(() => {})} + > + + + {notifSettings.arrivalLeadMinutes}m + updateNotificationSettings({ + arrivalLeadMinutes: clamp(notifSettings.arrivalLeadMinutes + 1, MIN_NOTIF_MINUTES, MAX_NOTIF_MINUTES), + }).catch(() => {})} + > + + + + + + + {t('flightNotifDepartureLead')} + + updateNotificationSettings({ + departureLeadMinutes: clamp(notifSettings.departureLeadMinutes - 1, MIN_NOTIF_MINUTES, MAX_NOTIF_MINUTES), + }).catch(() => {})} + > + + + {notifSettings.departureLeadMinutes}m + updateNotificationSettings({ + departureLeadMinutes: clamp(notifSettings.departureLeadMinutes + 1, MIN_NOTIF_MINUTES, MAX_NOTIF_MINUTES), + }).catch(() => {})} + > + + + + + + + + + + setNotifDialog(null)} + > + + setNotifDialog(null)} /> + + + + + + {notifDialog?.title} + + {notifDialog?.message} + setNotifDialog(null)} activeOpacity={0.85}> + OK + + + + ); } function makeStyles(c: ThemeColors) { + const filterOptionActiveShadow = Platform.OS === 'android' + ? {} + : { + shadowColor: c.primary, + shadowOffset: { width: 0, height: 3 }, + shadowOpacity: c.isDark ? 0.25 : 0.16, + shadowRadius: 7, + }; + return StyleSheet.create({ pageHeader: { backgroundColor: c.card, paddingHorizontal: 16, paddingVertical: 14, borderBottomWidth: 1, borderBottomColor: c.border, flexDirection: 'row', alignItems: 'center' }, notifBtn: { width: 42, height: 42, borderRadius: 21, backgroundColor: c.cardSecondary, justifyContent: 'center', alignItems: 'center' }, @@ -1028,18 +1868,83 @@ function makeStyles(c: ThemeColors) { opsIcon: { fontSize: 16 }, opsLabel: { fontSize: 10, fontWeight: '600', color: c.textSub, letterSpacing: 0.5 }, opsTime: { fontSize: 13, fontWeight: '800', color: c.primaryDark }, + arrivalProgressSection: { marginTop: 12 }, + arrivalProgressMetaRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 6 }, + arrivalProgressEndpoint: { flexDirection: 'row', alignItems: 'center', gap: 5 }, + arrivalProgressTime: { fontSize: 11, fontWeight: '800', color: c.text }, + arrivalProgressTrackWrap: { position: 'relative', justifyContent: 'center', height: 28 }, + arrivalProgressTrack: { height: 4, borderRadius: 999, backgroundColor: c.border, overflow: 'hidden' }, + arrivalProgressFill: { height: '100%', borderRadius: 999 }, + arrivalProgressPlaneWrap: { position: 'absolute', top: 0, marginLeft: -11 }, + arrivalProgressPlaneBadge: { + width: 22, + height: 22, + borderRadius: 11, + backgroundColor: c.card, + borderWidth: 1.5, + borderColor: c.primaryLight, + justifyContent: 'center', + alignItems: 'center', + shadowColor: c.isDark ? '#000' : c.primary, + shadowOpacity: c.isDark ? 0.2 : 0.16, + shadowRadius: 4, + shadowOffset: { width: 0, height: 2 }, + elevation: 3, + }, + arrivalProgressPlaneIcon: { transform: [{ rotate: '90deg' }] }, 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' }, + alertOverlay: { flex: 1, backgroundColor: 'rgba(2,6,23,0.55)', justifyContent: 'center', alignItems: 'center', padding: 24 }, + alertCard: { + width: '100%', + maxWidth: 440, + borderRadius: 20, + padding: 18, + backgroundColor: c.card, + borderWidth: 1, + borderColor: c.glassBorder, + }, + alertHeader: { flexDirection: 'row', alignItems: 'center', marginBottom: 12, gap: 10 }, + alertIconWrap: { width: 34, height: 34, borderRadius: 17, alignItems: 'center', justifyContent: 'center' }, + alertSuccess: { backgroundColor: '#16A34A' }, + alertWarning: { backgroundColor: '#EA580C' }, + alertInfo: { backgroundColor: c.primary }, + alertTitle: { flex: 1, fontSize: 28, fontWeight: '900', color: c.text }, + alertMessage: { fontSize: 17, lineHeight: 24, color: c.textSub, marginBottom: 16 }, + alertBtn: { alignSelf: 'flex-end', paddingHorizontal: 18, paddingVertical: 10, borderRadius: 12, backgroundColor: c.primary }, + alertBtnTxt: { color: '#fff', fontSize: 15, fontWeight: '800' }, 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 }, + notifSheetSub: { fontSize: 13, color: c.textSub, textAlign: 'center', marginTop: -8, marginBottom: 16 }, + notifRow: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 10 }, + notifRowTextWrap: { flex: 1 }, + notifRowTitle: { fontSize: 14, fontWeight: '700', color: c.text }, + notifRowSub: { fontSize: 12, color: c.textSub, marginTop: 2 }, + notifDivider: { height: 1, backgroundColor: c.border, marginVertical: 10 }, + notifMinutesRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 10 }, + notifStepper: { flexDirection: 'row', alignItems: 'center', backgroundColor: c.bg, borderRadius: 10, padding: 4 }, + notifStepperBtn: { width: 32, height: 32, borderRadius: 8, alignItems: 'center', justifyContent: 'center', backgroundColor: c.card }, + notifStepperValue: { minWidth: 54, textAlign: 'center', fontSize: 14, fontWeight: '800', color: c.primaryDark }, + filterOption: { flexDirection: 'row', alignItems: 'center', gap: 12, padding: 14, borderRadius: 14, marginBottom: 8, borderWidth: 1.5 }, + filterOptionActive: { + borderWidth: 1.5, + ...filterOptionActiveShadow, + }, filterOptionText: { fontSize: 15, fontWeight: '600', color: c.text }, filterOptionSub: { fontSize: 12, color: c.textSub, marginTop: 2 }, + filterBrandDotWrap: { + width: 22, + height: 22, + borderRadius: 11, + justifyContent: 'center', + alignItems: 'center', + borderWidth: 1, + }, + filterBrandDot: { width: 10, height: 10, borderRadius: 5 }, 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 }, diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index 0f8e1d5..7779ce3 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -5,7 +5,7 @@ import { Platform, UIManager } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import { MaterialIcons } from '@expo/vector-icons'; +import { MaterialCommunityIcons, MaterialIcons } from '@expo/vector-icons'; import { WebView } from 'react-native-webview'; import * as ImagePicker from 'expo-image-picker'; import * as Calendar from 'expo-calendar'; @@ -30,13 +30,6 @@ if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental const PINNED_FLIGHT_KEY = 'pinned_flight_v1'; 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: '🌧️' }, -}; - // months comes from useLanguage() context const engineHtml = ` @@ -155,10 +148,10 @@ export default function HomeScreen({ isFocused }: { isFocused?: boolean }) { const { t, months, locale, weatherMap } = useLanguage(); const [timelineKey, setTimelineKey] = React.useState(0); React.useEffect(() => { if (isFocused) setTimelineKey(k => k + 1); }, [isFocused]); - const HOME_SHIFT_TITLES = { work: t('homeShiftWork'), rest: '🌴 Riposo' }; + const HOME_SHIFT_TITLES = { work: 'Lavoro', rest: 'Riposo' }; const today = new Date(); const [shiftEvent, setShiftEvent] = useState(null); - const [weather, setWeather] = useState<{ text: string; icon: string; temp: number } | null>(null); + const [weather, setWeather] = useState<{ text: string; iconName: string; temp: number } | null>(null); const [loadingShift, setLoadingShift] = useState(true); const [uploadOpen, setUploadOpen] = useState(false); const [imageList, setImageList] = useState([]); @@ -274,7 +267,7 @@ export default function HomeScreen({ isFocused }: { isFocused?: boolean }) { const json = await res.json(); const code = json.current?.weather_code ?? 0; const temp = Math.round(json.current?.temperature_2m ?? 0); - const w = weatherMap[code] || { text: 'Sereno', icon: 'β˜€οΈ' }; + const w = weatherMap[code] || { text: 'Sereno', iconName: 'weather-sunny' }; setWeather({ ...w, temp }); } catch (e) { if (__DEV__) console.warn('[weather]', e); } }; @@ -375,7 +368,12 @@ export default function HomeScreen({ isFocused }: { isFocused?: boolean }) { {weather ? ( <> - {weather.icon} + {weather.temp}Β° {t('homeWeatherLocal')} β€’ {weather.text} @@ -414,7 +412,9 @@ export default function HomeScreen({ isFocused }: { isFocused?: boolean }) { ) : isRest ? ( - 🌴 + + + {t('homeRestDay')} ) : ( @@ -444,7 +444,7 @@ function makeStyles(c: ThemeColors) { 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: 18, padding: 16, alignItems: 'center', shadowColor: c.isDark ? '#000000' : c.primary, shadowOpacity: 0.12, shadowRadius: 12, elevation: 4, borderWidth: 1, borderColor: c.glassBorder }, - weatherEmoji: { fontSize: 28, marginBottom: 4 }, + weatherIcon: { marginBottom: 4 }, 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: 18, padding: 14, alignItems: 'center', justifyContent: 'center', shadowColor: c.isDark ? '#000000' : c.primary, shadowOpacity: 0.30, shadowRadius: 12, elevation: 6 }, @@ -461,6 +461,7 @@ function makeStyles(c: ThemeColors) { 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: 0.08, shadowRadius: 10, elevation: 3, borderWidth: 1, borderColor: c.glassBorder }, restRow: { flexDirection: 'row', alignItems: 'center' }, + restIconWrap: { width: 40, height: 40, borderRadius: 12, backgroundColor: '#10b98122', alignItems: 'center', justifyContent: 'center', marginRight: 12 }, restText: { fontSize: 18, fontWeight: '700', 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: 18, paddingHorizontal: 16, paddingVertical: 14, shadowColor: c.isDark ? '#000000' : c.primary, shadowOpacity: 0.08, shadowRadius: 8, elevation: 3, borderWidth: 1, borderColor: c.glassBorder }, diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index 8b35d7d..b4c995c 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { View, Text, StyleSheet, ScrollView, TouchableOpacity, Switch, ActivityIndicator, - Alert, Modal, KeyboardAvoidingView, Platform, TextInput, Linking, + Modal, KeyboardAvoidingView, Platform, TextInput, } from 'react-native'; import { LinearGradient } from 'expo-linear-gradient'; import { MaterialIcons } from '@expo/vector-icons'; @@ -23,7 +23,6 @@ import { } from '../utils/updateChecker'; import UpdateModal from '../components/UpdateModal'; import { exportBackup, importBackup } from '../utils/backupManager'; -import { getStaffMonitorDebugStatus, getStaffMonitorDebugColumns, getStaffMonitorDebugFlights } from '../utils/staffMonitor'; // ─── Tema picker ────────────────────────────────────────────────────────────── type ThemeOption = { @@ -169,12 +168,31 @@ function SettingRow({ ); } +type DialogTone = 'success' | 'error' | 'warning' | 'info'; +type DialogAction = { + label: string; + style?: 'primary' | 'secondary' | 'danger'; + onPress?: () => void | Promise; +}; +type DialogState = { + title: string; + message: string; + tone: DialogTone; + actions?: DialogAction[]; + scrollable?: boolean; +}; + +type SettingsScreenProps = { + onOpenFlightNotifications?: () => void; +}; + // ─── Main ───────────────────────────────────────────────────────────────────── -export default function SettingsScreen() { +export default function SettingsScreen({ onOpenFlightNotifications }: SettingsScreenProps) { 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 [dialogState, setDialogState] = useState(null); const translatedOptions = THEME_OPTIONS.map(opt => ({ ...opt, @@ -187,27 +205,51 @@ export default function SettingsScreen() { const [showUpdateModal, setShowUpdateModal] = useState(false); const [exportingBackup, setExportingBackup] = useState(false); const [importingBackup, setImportingBackup] = useState(false); - useEffect(() => { getCachedUpdateInfo().then(setUpdateInfo); }, []); + const showDialog = useCallback((dialog: DialogState) => { + setDialogState(dialog); + }, []); + + const closeDialog = useCallback(() => { + setDialogState(null); + }, []); + + const handleDialogAction = useCallback((action?: DialogAction) => { + setDialogState(null); + Promise.resolve() + .then(() => action?.onPress?.()) + .catch(() => {}); + }, []); + const handleCheckUpdate = useCallback(async () => { setCheckingUpdate(true); const info = await checkForUpdate(true); setUpdateInfo(info); setCheckingUpdate(false); if (!info) { - Alert.alert('Errore', 'Impossibile contattare GitHub. Riprova piΓΉ tardi.'); + showDialog({ + title: t('error'), + message: t('updateCheckErrorMessage'), + tone: 'error', + }); } else if (info.available) { setShowUpdateModal(true); } else { - Alert.alert('Sei aggiornato!', `AeroStaff Pro v${APP_VERSION} Γ¨ l'ultima versione.`); + showDialog({ + title: t('updateCheckOkTitle'), + message: t('updateCheckOkMessage').replace('{version}', APP_VERSION), + tone: 'success', + }); } - }, []); + }, [showDialog, t]); const handleDownload = useCallback(() => { - if (updateInfo?.downloadUrl) Linking.openURL(updateInfo.downloadUrl); + if (updateInfo?.available) { + setShowUpdateModal(true); + } }, [updateInfo]); const handleExport = useCallback(async () => { @@ -215,35 +257,52 @@ export default function SettingsScreen() { const result = await exportBackup(); setExportingBackup(false); if (result.ok) { - Alert.alert('Backup esportato', 'File salvato nella cartella selezionata.'); + showDialog({ + title: 'Backup esportato', + message: 'File salvato nella cartella selezionata. Password e PIN non vengono inclusi per sicurezza.', + tone: 'success', + }); } else if (result.error !== 'Permesso negato' && result.error !== 'Annullato') { - Alert.alert('Errore', result.error); + showDialog({ + title: t('error'), + message: result.error, + tone: 'error', + }); } - }, []); + }, [showDialog, t]); const handleImport = useCallback(async () => { - Alert.alert( - 'Importa backup', - 'I dati attuali (note, password, blocco note) saranno sovrascritti con quelli del backup. Continuare?', - [ - { text: 'Annulla', style: 'cancel' }, + showDialog({ + title: 'Importa backup', + message: 'I dati compatibili del backup (note, rubrica, manuali e impostazioni) sovrascriveranno quelli attuali. I backup recenti non includono password e PIN. Continuare?', + tone: 'warning', + actions: [ + { label: t('cancel'), style: 'secondary' }, { - text: 'Importa', - style: 'destructive', + label: 'Importa', + style: 'danger', onPress: async () => { setImportingBackup(true); const result = await importBackup(); setImportingBackup(false); if (result.ok) { - Alert.alert('Backup importato', 'Riavvia l\'app per applicare tutte le modifiche.'); + showDialog({ + title: 'Backup importato', + message: 'Riavvia l\'app per applicare tutte le modifiche.', + tone: 'success', + }); } else if (result.error !== 'Annullato') { - Alert.alert('Errore', result.error); + showDialog({ + title: t('error'), + message: result.error, + tone: 'error', + }); } }, }, ], - ); - }, []); + }); + }, [showDialog, t]); const openAirportModal = () => { setAirportInput(airportCode); @@ -258,19 +317,43 @@ export default function SettingsScreen() { const saveAirport = async () => { const normalized = normalizeAirportCode(airportInput); if (!isValidAirportCode(normalized)) { - Alert.alert(t('airportAlertInvalidTitle'), t('airportAlertInvalidMsg')); + showDialog({ + title: t('airportAlertInvalidTitle'), + message: t('airportAlertInvalidMsg'), + tone: 'error', + }); return; } try { await setAirportCode(normalized); setAirportModalOpen(false); - Alert.alert(t('airportAlertUpdatedTitle'), t('airportAlertUpdatedMsg')); + showDialog({ + title: t('airportAlertUpdatedTitle'), + message: t('airportAlertUpdatedMsg'), + tone: 'success', + }); } catch { - Alert.alert(t('airportAlertErrorTitle'), t('airportAlertErrorMsg')); + showDialog({ + title: t('airportAlertErrorTitle'), + message: t('airportAlertErrorMsg'), + tone: 'error', + }); } }; + const handleOpenFlightNotifications = useCallback(() => { + if (onOpenFlightNotifications) { + onOpenFlightNotifications(); + return; + } + showDialog({ + title: t('notifFlights'), + message: t('notifFlightsSub'), + tone: 'info', + }); + }, [onOpenFlightNotifications, showDialog, t]); + return ( <> {t('sectionNotifications')} - + @@ -348,16 +437,6 @@ export default function SettingsScreen() { {t('sectionApp')} - - Alert.alert('StaffMonitor debug', `Stato: ${getStaffMonitorDebugStatus()}\n\nColonne:\n${getStaffMonitorDebugColumns()}\n\nVoli (D, primi 5):\n${getStaffMonitorDebugFlights()}`)} activeOpacity={0.8}> - - - - - Debug StaffMonitor - Tocca per vedere colonne rilevate - - {/* ── Sezione Aggiornamenti ── */} @@ -407,7 +486,7 @@ export default function SettingsScreen() { > - Scarica v{updateInfo.latestVersion} + Gestisci v{updateInfo.latestVersion.replace(/^v/i, '')} )} @@ -461,7 +540,7 @@ export default function SettingsScreen() { activeOpacity={0.8} > - {langOpt.flag} + {langOpt.label} @@ -487,6 +566,99 @@ export default function SettingsScreen() { /> )} + {dialogState && ( + + + + + + + + {dialogState.title} + {dialogState.scrollable ? ( + + {dialogState.message} + + ) : ( + {dialogState.message} + )} + + {(dialogState.actions ?? [{ label: t('ok'), style: 'primary' }]).map((action, index) => { + const actionStyle = action.style ?? 'primary'; + return ( + { handleDialogAction(action); }} + activeOpacity={0.85} + > + + {action.label} + + + ); + })} + + + + + )} + " + s.raw, @@ -194,7 +195,7 @@ export default function ShiftScreen() { if (savedCount > 0) { Alert.alert( - "βœ… Turni Sincronizzati!", + t('shiftSyncOkTitle'), `${savedCount} turni salvati nel calendario.` ); } else { @@ -252,7 +253,10 @@ export default function ShiftScreen() { - πŸ“… Sincronizzazione Calendario + + + {t('shiftSyncTitle')} + Seleziona gli screenshot del tuo tabellone orari. Il sistema li leggerΓ  per cercare e salvare automaticamente i voli nel calendario del tuo telefono. @@ -260,7 +264,10 @@ export default function ShiftScreen() { - πŸ“· Scansiona Screenshot Turni + + + {t('shiftScanBtn')} + @@ -285,7 +292,10 @@ export default function ShiftScreen() { {ocrText} - βœ… Sincronizza nel Calendario! + + + {t('shiftSyncBtn')} + ) : null} @@ -314,7 +324,8 @@ const styles = StyleSheet.create({ borderLeftWidth: 4, borderLeftColor: PRIMARY, shadowColor: '#000', shadowOpacity: 0.05, shadowRadius: 6, elevation: 2, }, - infoTitle: { fontWeight: 'bold', fontSize: 15, marginBottom: 8, color: PRIMARY }, + infoTitleRow: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 8 }, + infoTitle: { fontWeight: 'bold', fontSize: 15, color: PRIMARY }, infoDesc: { fontSize: 13, color: '#6B7280', lineHeight: 20 }, buttonsContainer: { margin: 16, marginBottom: 0 }, button: { @@ -323,6 +334,7 @@ const styles = StyleSheet.create({ alignItems: 'center', shadowColor: DARK_ORANGE, shadowOpacity: 0.3, shadowRadius: 8, elevation: 5, }, + buttonInner: { flexDirection: 'row', alignItems: 'center', gap: 8 }, 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' }, diff --git a/src/utils/airportSettings.ts b/src/utils/airportSettings.ts index 981a5e1..372cb57 100644 --- a/src/utils/airportSettings.ts +++ b/src/utils/airportSettings.ts @@ -1,5 +1,5 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; -import { ALLOWED_AIRLINES } from './airlineOps'; +import { ALLOWED_AIRLINES, AIRLINE_DISPLAY_NAMES } from './airlineOps'; export type AirportPreset = { code: string; @@ -13,6 +13,7 @@ export type AirportInfo = AirportPreset & { }; export const AIRPORT_STORAGE_KEY = 'aerostaff_airport_code_v1'; +export const AIRPORT_AIRLINES_STORAGE_KEY = 'aerostaff_airport_airlines_v1'; export const DEFAULT_AIRPORT_CODE = 'PSA'; export const AIRPORT_PRESETS: AirportPreset[] = [ @@ -45,9 +46,147 @@ export const AIRPORT_AIRLINES: Record = { PMO: ['ryanair', 'easyjet', 'wizz', 'volotea', 'vueling'], }; +const airportAirlinesCache: Record = Object.fromEntries( + Object.entries(AIRPORT_AIRLINES).map(([code, airlines]) => [code, [...airlines]]), +); + +function normalizeAirlineKey(value: string | null | undefined): string { + return (value ?? '').trim().toLowerCase().replace(/\s+/g, ' '); +} + +const AIRLINE_CANONICAL_RULES: Array<{ canonical: string; needles: string[] }> = [ + { canonical: 'ryanair', needles: ['ryanair'] }, + { canonical: 'easyjet', needles: ['easyjet', 'easyjet europe', 'easyjet switzerland', 'easyjet uk'] }, + { canonical: 'wizz', needles: ['wizz', 'wizz air malta', 'wizz air uk', 'wizz air abu dhabi'] }, + { canonical: 'volotea', needles: ['volotea'] }, + { canonical: 'vueling', needles: ['vueling'] }, + { canonical: 'transavia', needles: ['transavia france', 'transavia holland', 'transavia airlines', 'transavia'] }, + { canonical: 'aer lingus', needles: ['aer lingus'] }, + { canonical: 'british airways', needles: ['british airways'] }, + { canonical: 'sas', needles: ['sas', 'scandinavian'] }, + { canonical: 'flydubai', needles: ['flydubai'] }, +]; + +function canonicalizeAirlineKey(value: string | null | undefined): string { + const normalized = normalizeAirlineKey(value); + if (!normalized) { + return ''; + } + + for (const rule of AIRLINE_CANONICAL_RULES) { + if (rule.needles.some(needle => normalized.includes(needle))) { + return rule.canonical; + } + } + + return normalized; +} + +function sortAirlineKeys(values: string[]): string[] { + return [...values].sort((left, right) => { + const leftLabel = AIRLINE_DISPLAY_NAMES[left] ?? left; + const rightLabel = AIRLINE_DISPLAY_NAMES[right] ?? right; + return leftLabel.localeCompare(rightLabel, 'en', { sensitivity: 'base' }); + }); +} + +function sanitizeAirlineList(values: string[], fallback: string[] = ALLOWED_AIRLINES): string[] { + const unique = Array.from(new Set(values.map(canonicalizeAirlineKey).filter(Boolean))); + if (unique.length === 0) { + return [...fallback]; + } + + return sortAirlineKeys(unique); +} + +function normalizeAirportAirlineMap(raw: unknown): Record { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { + return {}; + } + + const entries = Object.entries(raw as Record) + .map(([airportCode, airlines]) => { + const normalizedCode = normalizeAirportCode(airportCode); + if (!normalizedCode || !Array.isArray(airlines)) { + return null; + } + + return [normalizedCode, sanitizeAirlineList(airlines as string[], [])] as const; + }) + .filter((entry): entry is readonly [string, string[]] => entry !== null); + + return Object.fromEntries(entries); +} + +export function primeAirportAirlinesCache(map: Record): void { + Object.entries(normalizeAirportAirlineMap(map)).forEach(([airportCode, airlines]) => { + airportAirlinesCache[airportCode] = airlines; + }); +} + +export async function getStoredAirportAirlineMap(): Promise> { + try { + const raw = await AsyncStorage.getItem(AIRPORT_AIRLINES_STORAGE_KEY); + const parsed = raw ? normalizeAirportAirlineMap(JSON.parse(raw)) : {}; + primeAirportAirlinesCache(parsed); + return parsed; + } catch { + return {}; + } +} + +export async function getStoredAirportAirlines(code: string | null | undefined): Promise { + const normalized = isValidAirportCode(code) ? normalizeAirportCode(code) : DEFAULT_AIRPORT_CODE; + const stored = await getStoredAirportAirlineMap(); + return stored[normalized] ?? getAirportAirlines(normalized); +} + +export function extractAirportAirlinesFromSchedule(...sources: unknown[]): string[] { + const detected = sources.flatMap(source => { + if (!Array.isArray(source)) { + return []; + } + + return source + .map(item => typeof item === 'string' ? item : item?.flight?.airline?.name) + .filter((name): name is string => typeof name === 'string' && name.trim().length > 0); + }); + + return sanitizeAirlineList(detected, []); +} + +function arraysEqual(left: string[], right: string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]); +} + +export async function storeDetectedAirportAirlines(code: string | null | undefined, ...sources: unknown[]): Promise { + const normalizedCode = isValidAirportCode(code) ? normalizeAirportCode(code) : DEFAULT_AIRPORT_CODE; + const detected = extractAirportAirlinesFromSchedule(...sources); + const fallback = AIRPORT_AIRLINES[normalizedCode] ?? ALLOWED_AIRLINES; + const currentStoredMap = await getStoredAirportAirlineMap(); + const current = currentStoredMap[normalizedCode] ?? getAirportAirlines(normalizedCode); + const next = sanitizeAirlineList([...fallback, ...current, ...detected], fallback); + + airportAirlinesCache[normalizedCode] = next; + + if (arraysEqual(next, currentStoredMap[normalizedCode] ?? [])) { + return next; + } + + await AsyncStorage.setItem( + AIRPORT_AIRLINES_STORAGE_KEY, + JSON.stringify({ + ...currentStoredMap, + [normalizedCode]: next, + }), + ); + + return next; +} + export function getAirportAirlines(code: string | null | undefined): string[] { - const normalized = normalizeAirportCode(code); - return AIRPORT_AIRLINES[normalized] ?? ALLOWED_AIRLINES; + const normalized = isValidAirportCode(code) ? normalizeAirportCode(code) : DEFAULT_AIRPORT_CODE; + return airportAirlinesCache[normalized] ?? AIRPORT_AIRLINES[normalized] ?? ALLOWED_AIRLINES; } const AIRPORT_MAP = Object.fromEntries( diff --git a/src/utils/autoNotifications.ts b/src/utils/autoNotifications.ts index c2edd58..3cfe69d 100644 --- a/src/utils/autoNotifications.ts +++ b/src/utils/autoNotifications.ts @@ -11,6 +11,39 @@ import { const NOTIF_IDS_KEY = 'aerostaff_notif_ids_v1'; const LAST_SCHEDULE_KEY = 'aerostaff_notif_last_schedule'; +const FLIGHT_FILTER_STORAGE_KEY = 'aerostaff_flight_filter_v1'; + +function normalizeAirline(value: unknown): string { + return typeof value === 'string' + ? value.trim().toLowerCase().replace(/\s+/g, ' ') + : ''; +} + +function isFlightCoveredByProfile(item: any, selectedAirlines: string[]): boolean { + if (selectedAirlines.length === 0) { + return false; + } + + const airlineName = normalizeAirline(item?.flight?.airline?.name); + if (!airlineName) { + return false; + } + + return selectedAirlines.some(key => airlineName.includes(key)); +} + +function parseSelectedAirlines(raw: string | null): string[] { + if (!raw) { + return []; + } + + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.map(normalizeAirline).filter(Boolean) : []; + } catch { + return []; + } +} async function cancelPrevious() { const raw = await AsyncStorage.getItem(NOTIF_IDS_KEY); @@ -62,16 +95,25 @@ export async function autoScheduleNotifications(): Promise { // Fetch departures and arrivals from FR24 using the selected airport const { departures: allDepartures, arrivals: allArrivals } = await fetchAirportScheduleRaw(); - // Filter departures during shift + const selectedAirlinesRaw = await AsyncStorage.getItem(FLIGHT_FILTER_STORAGE_KEY); + const selectedAirlines = parseSelectedAirlines(selectedAirlinesRaw); + + // Filter departures during shift + selected profile airlines const shiftDepartures = allDepartures.filter((item: any) => { const ts = item.flight?.time?.scheduled?.departure; - return ts && ts >= shiftStart && ts <= shiftEnd; + return ts + && ts >= shiftStart + && ts <= shiftEnd + && isFlightCoveredByProfile(item, selectedAirlines); }); - // Filter arrivals during shift (inbound aircraft that become our departures) + // Filter arrivals during shift + selected profile airlines const shiftArrivals = allArrivals.filter((item: any) => { const ts = item.flight?.time?.scheduled?.arrival; - return ts && ts >= shiftStart && ts <= shiftEnd; + return ts + && ts >= shiftStart + && ts <= shiftEnd + && isFlightCoveredByProfile(item, selectedAirlines); }); // ── Persistent ongoing shift notification ────────────────────────────────── @@ -94,7 +136,7 @@ export async function autoScheduleNotifications(): Promise { 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`; + flightInfo = `Prossima: ${fn} per ${dest} alle ${time} Β· ${shiftDepartures.length} voli oggi`; } else { flightInfo = `${shiftDepartures.length} voli Β· Nessuna partenza imminente`; } @@ -109,12 +151,12 @@ export async function autoScheduleNotifications(): Promise { await cancelPrevious(); const newIds: string[] = []; - // ── Arrival notifications: 15 min before landing ── + // ── Arrival notifications: 10 min before landing ── for (const item of shiftArrivals) { try { const arrTs: number | undefined = item.flight?.time?.scheduled?.arrival; if (!arrTs || isNaN(arrTs)) continue; - const secondsUntilNotify = arrTs - 15 * 60 - now; + const secondsUntilNotify = arrTs - 10 * 60 - now; if (secondsUntilNotify <= 0 || isNaN(secondsUntilNotify)) continue; const flightNumber = item.flight?.identification?.number?.default || 'N/A'; @@ -125,10 +167,10 @@ export async function autoScheduleNotifications(): Promise { const id = await Notifications.scheduleNotificationAsync({ content: { - title: `✈️ Arrivo tra 15 min β€” ${flightNumber}`, + title: `Atterraggio tra 10 min - ${flightNumber}`, body: `${airline} da ${origin} Β· arrivo alle ${arrivalTime}`, sound: true, - data: { flightNumber, arrTs, type: 'arrival_15min' }, + data: { flightNumber, arrTs, type: 'arrival_10min' }, }, trigger: { type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL, seconds: Math.round(secondsUntilNotify), repeats: false }, }); @@ -138,7 +180,7 @@ export async function autoScheduleNotifications(): Promise { } } - // ── Departure notifications: check-in open + gate open ── + // ── Departure notifications: check-in/gate open-close warnings ── for (const item of shiftDepartures) { try { const depTs: number | undefined = item.flight?.time?.scheduled?.departure; @@ -153,36 +195,72 @@ export async function autoScheduleNotifications(): Promise { // Get airline-specific ops times const ops = getAirlineOps(airline); - // Notification at check-in open (e.g. 2h before departure) + // Check-in open/close timestamps const ciOpenTs = depTs - ops.checkInOpen * 60; - const secondsUntilCI = ciOpenTs - now; - if (secondsUntilCI > 0 && !isNaN(secondsUntilCI)) { + const ciCloseTs = depTs - ops.checkInClose * 60; + const gateOpenTs = depTs - ops.gateOpen * 60; + const gateCloseTs = depTs - ops.gateClose * 60; + + // Notification 10 min before check-in open + const secondsUntilCIOpenWarn = ciOpenTs - 10 * 60 - now; + if (secondsUntilCIOpenWarn > 0 && !isNaN(secondsUntilCIOpenWarn)) { const ciTime = new Date(ciOpenTs * 1000).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }); const id = await Notifications.scheduleNotificationAsync({ content: { - title: `πŸ“Œ Check-in aperto β€” ${flightNumber}`, - body: `${airline} per ${destination} Β· partenza ${depTime} Β· CI dalle ${ciTime}`, + title: `Check-in apre tra 10 min - ${flightNumber}`, + body: `${airline} per ${destination} Β· CI apre alle ${ciTime} Β· partenza ${depTime}`, sound: true, - data: { flightNumber, depTs, type: 'checkin_open' }, + data: { flightNumber, depTs, type: 'checkin_open_10min' }, }, - trigger: { type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL, seconds: Math.round(secondsUntilCI), repeats: false }, + trigger: { type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL, seconds: Math.round(secondsUntilCIOpenWarn), repeats: false }, }); newIds.push(id); } - // Notification at gate open - const gateOpenTs = depTs - ops.gateOpen * 60; - const secondsUntilGate = gateOpenTs - now; - if (secondsUntilGate > 0 && !isNaN(secondsUntilGate)) { + // Notification 10 min before check-in close + const secondsUntilCICloseWarn = ciCloseTs - 10 * 60 - now; + if (secondsUntilCICloseWarn > 0 && !isNaN(secondsUntilCICloseWarn)) { + const ciCloseTime = new Date(ciCloseTs * 1000).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }); + const id = await Notifications.scheduleNotificationAsync({ + content: { + title: `Check-in chiude tra 10 min - ${flightNumber}`, + body: `${airline} per ${destination} Β· chiusura CI alle ${ciCloseTime} Β· partenza ${depTime}`, + sound: true, + data: { flightNumber, depTs, type: 'checkin_close_10min' }, + }, + trigger: { type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL, seconds: Math.round(secondsUntilCICloseWarn), repeats: false }, + }); + newIds.push(id); + } + + // Notification 5 min before gate open + const secondsUntilGateOpenWarn = gateOpenTs - 5 * 60 - now; + if (secondsUntilGateOpenWarn > 0 && !isNaN(secondsUntilGateOpenWarn)) { const gateTime = new Date(gateOpenTs * 1000).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }); const id = await Notifications.scheduleNotificationAsync({ content: { - title: `πŸšͺ Gate aperto β€” ${flightNumber}`, - body: `${airline} per ${destination} Β· gate dalle ${gateTime} Β· partenza ${depTime}`, + title: `Gate apre tra 5 min - ${flightNumber}`, + body: `${airline} per ${destination} Β· gate apre alle ${gateTime} Β· partenza ${depTime}`, + sound: true, + data: { flightNumber, depTs, type: 'gate_open_5min' }, + }, + trigger: { type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL, seconds: Math.round(secondsUntilGateOpenWarn), repeats: false }, + }); + newIds.push(id); + } + + // Notification 5 min before gate close + const secondsUntilGateCloseWarn = gateCloseTs - 5 * 60 - now; + if (secondsUntilGateCloseWarn > 0 && !isNaN(secondsUntilGateCloseWarn)) { + const gateCloseTime = new Date(gateCloseTs * 1000).toLocaleTimeString('it-IT', { hour: '2-digit', minute: '2-digit' }); + const id = await Notifications.scheduleNotificationAsync({ + content: { + title: `Gate chiude tra 5 min - ${flightNumber}`, + body: `${airline} per ${destination} Β· gate chiude alle ${gateCloseTime} Β· partenza ${depTime}`, sound: true, - data: { flightNumber, depTs, type: 'gate_open' }, + data: { flightNumber, depTs, type: 'gate_close_5min' }, }, - trigger: { type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL, seconds: Math.round(secondsUntilGate), repeats: false }, + trigger: { type: Notifications.SchedulableTriggerInputTypes.TIME_INTERVAL, seconds: Math.round(secondsUntilGateCloseWarn), repeats: false }, }); newIds.push(id); } diff --git a/src/utils/backupManager.ts b/src/utils/backupManager.ts index cf2815f..cba5cb8 100644 --- a/src/utils/backupManager.ts +++ b/src/utils/backupManager.ts @@ -1,17 +1,22 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import * as DocumentPicker from 'expo-document-picker'; import * as FileSystem from 'expo-file-system/legacy'; +import * as SecureStore from 'expo-secure-store'; -const BACKUP_VERSION = 1; +const BACKUP_VERSION = 2; -// Keys that represent user data worth preserving across reinstalls -const BACKUP_KEYS = [ +const PASSWORDS_KEY = 'aerostaff_passwords_v1'; +const PIN_KEY = 'aerostaff_pin_v1'; +const PIN_ENABLED_KEY = 'aerostaff_pin_enabled_v1'; + +// Only non-sensitive data is exported. Passwords and PINs stay in SecureStore. +const SAFE_BACKUP_KEYS = [ 'aerostaff_notepad_v1', - 'aerostaff_passwords_v1', - 'aerostaff_pin_v1', - 'aerostaff_pin_enabled_v1', 'aerostaff_phonebook_v1', 'aerostaff_airport_code_v1', + 'aerostaff_airport_airlines_v1', + 'aerostaff_airport_profiles_v1', + 'aerostaff_active_profile_id_v1', 'aerostaff_language_v1', 'aerostaff_theme_mode', 'aerostaff_flight_filter_v1', @@ -22,9 +27,38 @@ const BACKUP_KEYS = [ export type BackupResult = { ok: true } | { ok: false; error: string }; +async function importLegacySensitiveData(data: Record): Promise { + let imported = 0; + let hasImportedPin = false; + + const legacyPasswords = data[PASSWORDS_KEY]; + if (typeof legacyPasswords === 'string' && legacyPasswords.trim()) { + await SecureStore.setItemAsync(PASSWORDS_KEY, legacyPasswords); + await AsyncStorage.removeItem(PASSWORDS_KEY).catch(() => {}); + imported += 1; + } + + const legacyPin = data[PIN_KEY]; + if (typeof legacyPin === 'string' && legacyPin.trim()) { + await SecureStore.setItemAsync(PIN_KEY, legacyPin); + await AsyncStorage.removeItem(PIN_KEY).catch(() => {}); + hasImportedPin = true; + imported += 1; + } + + const legacyPinEnabled = data[PIN_ENABLED_KEY]; + if (typeof legacyPinEnabled === 'string') { + const nextPinEnabled = legacyPinEnabled === 'true' && hasImportedPin ? 'true' : 'false'; + await AsyncStorage.setItem(PIN_ENABLED_KEY, nextPinEnabled); + imported += 1; + } + + return imported; +} + export async function exportBackup(): Promise { try { - const pairs = await AsyncStorage.multiGet(BACKUP_KEYS); + const pairs = await AsyncStorage.multiGet(SAFE_BACKUP_KEYS); const data: Record = {}; for (const [key, value] of pairs) data[key] = value; @@ -67,13 +101,19 @@ export async function importBackup(): Promise { return { ok: false, error: 'Formato backup non riconosciuto' }; } - const pairs: [string, string][] = Object.entries(parsed.data) - .filter(([key, val]) => BACKUP_KEYS.includes(key) && val !== null && val !== undefined) + const data = parsed.data as Record; + const pairs: [string, string][] = Object.entries(data) + .filter(([key, val]) => SAFE_BACKUP_KEYS.includes(key) && val !== null && val !== undefined) .map(([key, val]) => [key, val as string]); + const importedLegacySensitive = await importLegacySensitiveData(data); - if (pairs.length === 0) return { ok: false, error: 'Nessun dato trovato nel backup' }; + if (pairs.length === 0 && importedLegacySensitive === 0) { + return { ok: false, error: 'Nessun dato trovato nel backup' }; + } - await AsyncStorage.multiSet(pairs); + if (pairs.length > 0) { + await AsyncStorage.multiSet(pairs); + } return { ok: true }; } catch (e: any) { return { ok: false, error: e?.message ?? 'Errore sconosciuto' }; diff --git a/src/utils/fr24api.ts b/src/utils/fr24api.ts index e6061a0..f58d647 100644 --- a/src/utils/fr24api.ts +++ b/src/utils/fr24api.ts @@ -5,6 +5,7 @@ import { getStoredAirportCode, isValidAirportCode, normalizeAirportCode, + storeDetectedAirportAirlines, type AirportInfo, } from './airportSettings'; @@ -27,6 +28,10 @@ export type FR24ScheduleRaw = { }; function filterAirlines(data: any[], allowedList: string[]) { + if (allowedList.length === 0) { + return data; + } + return data.filter(item => allowedList.some(key => (item.flight?.airline?.name || '').toLowerCase().includes(key)), ); @@ -56,6 +61,7 @@ export async function fetchAirportSchedule(code?: string): Promise const allArrivals = json.result?.response?.airport?.pluginData?.schedule?.arrivals?.data || []; const allDepartures = json.result?.response?.airport?.pluginData?.schedule?.departures?.data || []; + await storeDetectedAirportAirlines(airportCode, allArrivals, allDepartures); const airlines = getAirportAirlines(airportCode); return { arrivals: filterAirlines(allArrivals, airlines), @@ -87,6 +93,7 @@ export async function fetchAirportScheduleRaw(code?: string): Promise; +}; + +export type RuntimeExitInfo = { + timestamp: number; + reasonCode: number; + reasonLabel: string; + status: number; + importance: number; + processName?: string | null; + description?: string | null; + pssKb: number; + rssKb: number; + traceAvailable: boolean; +}; + +export type RuntimeDiagnosticsState = { + appVersion: string; + device: string; + androidVersion: string; + startupPending: boolean; + startupStartedAt?: number; + startupCompletedAt?: number; + logFilePath?: string; + lastExitInfo?: RuntimeExitInfo | null; + lastReport: RuntimeReport | null; +}; + +type RuntimeDiagnosticsNativeModule = { + initialDiagnosticsJson?: string; + getRuntimeDiagnostics?: () => Promise; + clearLastReport?: () => Promise; + markStartupCompleted?: () => Promise; + recordJsError?: ( + message: string, + stack: string, + isFatal: boolean, + source: string, + ) => Promise; +}; + +type ErrorUtilsShape = { + getGlobalHandler?: () => ((error: unknown, isFatal?: boolean) => void) | undefined; + setGlobalHandler?: (handler: (error: unknown, isFatal?: boolean) => void) => void; +}; + +const runtimeModule = NativeModules.RuntimeDiagnostics as RuntimeDiagnosticsNativeModule | undefined; + +function fallbackState(): RuntimeDiagnosticsState { + return { + appVersion: '', + device: '', + androidVersion: '', + startupPending: false, + lastReport: null, + }; +} + +function parseDiagnostics(payload?: string | null): RuntimeDiagnosticsState { + if (!payload) { + return fallbackState(); + } + + try { + const parsed = JSON.parse(payload) as Partial; + return { + ...fallbackState(), + ...parsed, + lastExitInfo: parsed.lastExitInfo ?? null, + lastReport: parsed.lastReport ?? null, + }; + } catch { + return fallbackState(); + } +} + +const initialRuntimeDiagnostics = parseDiagnostics(runtimeModule?.initialDiagnosticsJson); +let jsCrashHandlerInstalled = false; + +function normalizeError(error: unknown): { message: string; stack: string } { + if (error instanceof Error) { + return { + message: error.message || error.name || 'Unknown error', + stack: error.stack || '', + }; + } + + const message = typeof error === 'string' ? error : JSON.stringify(error); + return { + message: message || 'Unknown error', + stack: '', + }; +} + +export async function getRuntimeDiagnostics(): Promise { + if (!runtimeModule?.getRuntimeDiagnostics) { + return initialRuntimeDiagnostics; + } + + try { + const payload = await runtimeModule.getRuntimeDiagnostics(); + return parseDiagnostics(payload); + } catch { + return initialRuntimeDiagnostics; + } +} + +export async function clearLastRuntimeReport(): Promise { + await runtimeModule?.clearLastReport?.(); +} + +export async function markRuntimeStartupCompleted(): Promise { + await runtimeModule?.markStartupCompleted?.(); +} + +export async function recordRuntimeError( + error: unknown, + source: string, + isFatal = false, +): Promise { + if (!runtimeModule?.recordJsError) { + return; + } + + const normalized = normalizeError(error); + await runtimeModule.recordJsError( + normalized.message, + normalized.stack, + isFatal, + source, + ); +} + +export function installGlobalCrashHandler(): void { + if (jsCrashHandlerInstalled) { + return; + } + + const errorUtils = (globalThis as { ErrorUtils?: ErrorUtilsShape }).ErrorUtils; + const defaultHandler = errorUtils?.getGlobalHandler?.(); + if (!errorUtils?.setGlobalHandler || !defaultHandler) { + return; + } + + jsCrashHandlerInstalled = true; + errorUtils.setGlobalHandler((error, isFatal) => { + void recordRuntimeError(error, 'global', Boolean(isFatal)) + .catch(() => {}) + .finally(() => { + defaultHandler(error, isFatal); + }); + }); +} diff --git a/src/utils/staffMonitor.ts b/src/utils/staffMonitor.ts index 23dadc3..4281f7a 100644 --- a/src/utils/staffMonitor.ts +++ b/src/utils/staffMonitor.ts @@ -198,6 +198,7 @@ const FETCH_HEADERS = { // Tomcat JSESSIONID captured from D responses and forwarded to A requests. // The arrivals servlet likely requires an active session; departures may not. let _sessionCookie: string | null = null; +const _inFlightByNature: Partial>> = {}; function captureSessionCookie(resp: Response): void { const raw = resp.headers.get('set-cookie') ?? ''; @@ -227,6 +228,22 @@ async function tryFetch(url: string, timeoutMs: number): Promise { } } +function isAbortLikeError(error: unknown): boolean { + const msg = String(error ?? '').toLowerCase(); + return msg.includes('abort'); +} + +async function tryFetchWithRetry(url: string, timeoutMs: number): Promise { + try { + return await tryFetch(url, timeoutMs); + } catch (e) { + // Mobile networks can intermittently abort long requests even when the endpoint is healthy. + // Retry once with a longer timeout before considering this URL failed. + if (!isAbortLikeError(e)) throw e; + return tryFetch(url, Math.max(timeoutMs + 12_000, Math.round(timeoutMs * 1.5))); + } +} + /** * Fire all URLs simultaneously and resolve with the first successful HTML. * Returns null only if every URL fails or times out. @@ -236,7 +253,7 @@ function raceUrls(urls: string[], timeoutMs: number): Promise { let done = false; let pending = urls.length; for (const url of urls) { - tryFetch(url, timeoutMs) + tryFetchWithRetry(url, timeoutMs) .then(html => { if (!done) { done = true; resolve(html); } }) .catch(() => { pending--; if (pending === 0 && !done) { done = true; resolve(null); } }); } @@ -279,6 +296,10 @@ export function getStaffMonitorDebugFlights(): string { } export async function fetchStaffMonitorData(nature: 'D' | 'A'): Promise { + const running = _inFlightByNature[nature]; + if (running) return running; + + const run = (async () => { const base = 'https://servizi.pisa-airport.com/staffMonitor/staffMonitor'; // Primary URLs for the requested nature @@ -304,7 +325,7 @@ export async function fetchStaffMonitorData(nature: 'D' | 'A'): PromiseCACHE(${cached.length})`; return cached ?? []; } + })(); + + _inFlightByNature[nature] = run; + try { + return await run; + } finally { + delete _inFlightByNature[nature]; + } } diff --git a/src/utils/updateChecker.ts b/src/utils/updateChecker.ts index 1bfb9d4..854b67a 100644 --- a/src/utils/updateChecker.ts +++ b/src/utils/updateChecker.ts @@ -1,6 +1,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; +import { nativeApplicationVersion } from 'expo-application'; -export const APP_VERSION = '2.6.0'; +export const APP_VERSION = nativeApplicationVersion ?? '2.6.16'; const REPO = 'targetmisser/flightworkapp'; const CHECK_KEY = 'aerostaff_update_check_v1'; const SEEN_KEY = 'aerostaff_update_seen_v1'; @@ -8,12 +9,20 @@ const SEEN_KEY = 'aerostaff_update_seen_v1'; export type UpdateInfo = { available: boolean; latestVersion: string; - downloadUrl: string; + downloadUrl: string | null; releaseUrl: string; releaseNotes: string; + assetName: string | null; checkedAt: number; }; +function normalizeUpdateInfo(info: UpdateInfo): UpdateInfo { + return { + ...info, + available: isNewer(info.latestVersion, APP_VERSION), + }; +} + function parseVersion(v: string): number[] { return v.replace(/^v/, '').split('.').map(n => parseInt(n, 10) || 0); } @@ -35,14 +44,14 @@ export async function checkForUpdate(force = false): Promise if (!force) { const raw = await AsyncStorage.getItem(CHECK_KEY); if (raw) { - const cached: UpdateInfo = JSON.parse(raw); + const cached = normalizeUpdateInfo(JSON.parse(raw) as UpdateInfo); if (now - cached.checkedAt < 24 * 60 * 60 * 1000) return cached; } } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 10_000); - let json: any; + let json: Record; try { const resp = await fetch(`https://api.github.com/repos/${REPO}/releases/latest`, { signal: controller.signal, @@ -50,23 +59,33 @@ export async function checkForUpdate(force = false): Promise }); clearTimeout(timer); if (!resp.ok) return null; - json = await resp.json(); + json = await resp.json() as Record; } catch { clearTimeout(timer); return null; } - const tag: string = json.tag_name ?? ''; - const apkAsset = json.assets?.find((a: any) => (a.name as string).endsWith('.apk')); + const tag = typeof json.tag_name === 'string' ? json.tag_name : ''; + const assets = Array.isArray(json.assets) ? json.assets : []; + const apkAsset = assets.find((asset): asset is { name?: string; browser_download_url?: string } => { + if (!asset || typeof asset !== 'object') { + return false; + } + + const name = 'name' in asset ? asset.name : undefined; + return typeof name === 'string' && name.toLowerCase().endsWith('.apk'); + }); + const releaseUrl = typeof json.html_url === 'string' ? json.html_url : ''; - const info: UpdateInfo = { + const info = normalizeUpdateInfo({ available: isNewer(tag, APP_VERSION), latestVersion: tag, - downloadUrl: apkAsset?.browser_download_url ?? json.html_url, - releaseUrl: json.html_url ?? '', - releaseNotes: json.body ?? '', + downloadUrl: typeof apkAsset?.browser_download_url === 'string' ? apkAsset.browser_download_url : null, + releaseUrl, + releaseNotes: typeof json.body === 'string' ? json.body : '', + assetName: typeof apkAsset?.name === 'string' ? apkAsset.name : null, checkedAt: now, - }; + }); await AsyncStorage.setItem(CHECK_KEY, JSON.stringify(info)); return info; @@ -78,7 +97,7 @@ export async function checkForUpdate(force = false): Promise export async function getCachedUpdateInfo(): Promise { try { const raw = await AsyncStorage.getItem(CHECK_KEY); - return raw ? JSON.parse(raw) : null; + return raw ? normalizeUpdateInfo(JSON.parse(raw) as UpdateInfo) : null; } catch { return null; } diff --git a/src/utils/updateDownload.ts b/src/utils/updateDownload.ts new file mode 100644 index 0000000..39e39f8 --- /dev/null +++ b/src/utils/updateDownload.ts @@ -0,0 +1,136 @@ +import { Linking, Platform } from 'react-native'; +import * as Application from 'expo-application'; +import * as FileSystem from 'expo-file-system/legacy'; +import * as IntentLauncher from 'expo-intent-launcher'; + +import type { UpdateInfo } from './updateChecker'; + +const APK_MIME_TYPE = 'application/vnd.android.package-archive'; +const INSTALL_PACKAGE_ACTION = 'android.intent.action.INSTALL_PACKAGE'; +const FLAG_GRANT_READ_URI_PERMISSION = 1; +const EXTRA_RETURN_RESULT = 'android.intent.extra.RETURN_RESULT'; +const DOWNLOAD_DIR = FileSystem.documentDirectory ? `${FileSystem.documentDirectory}updates/` : null; + +export type UpdateDownloadProgress = { + receivedBytes: number; + totalBytes: number; + progress: number | null; +}; + +function ensureDownloadUrl(info: UpdateInfo): string { + if (!info.downloadUrl) { + throw new Error('Nessun file APK disponibile per questa release.'); + } + + return info.downloadUrl; +} + +function sanitizeSegment(value: string): string { + return value.replace(/[^a-z0-9._-]+/gi, '-').replace(/-+/g, '-'); +} + +function getFileName(info: UpdateInfo): string { + const assetName = info.assetName?.trim(); + if (assetName) { + return sanitizeSegment(assetName); + } + + return `AeroStaffPro-${sanitizeSegment(info.latestVersion || 'update')}.apk`; +} + +function getTargetUri(info: UpdateInfo): string { + if (!DOWNLOAD_DIR) { + throw new Error('La directory di download non Γ¨ disponibile su questo dispositivo.'); + } + + return `${DOWNLOAD_DIR}${getFileName(info)}`; +} + +async function ensureDownloadDirectory(): Promise { + if (!DOWNLOAD_DIR) { + throw new Error('La directory di download non Γ¨ disponibile su questo dispositivo.'); + } + + const dirInfo = await FileSystem.getInfoAsync(DOWNLOAD_DIR); + if (!dirInfo.exists) { + await FileSystem.makeDirectoryAsync(DOWNLOAD_DIR, { intermediates: true }); + } +} + +export async function getDownloadedUpdateUri(info: UpdateInfo): Promise { + if (!DOWNLOAD_DIR || !info.downloadUrl) { + return null; + } + + const fileUri = getTargetUri(info); + const fileInfo = await FileSystem.getInfoAsync(fileUri); + return fileInfo.exists && !fileInfo.isDirectory ? fileInfo.uri : null; +} + +export async function downloadUpdatePackage( + info: UpdateInfo, + onProgress?: (progress: UpdateDownloadProgress) => void, +): Promise { + const downloadUrl = ensureDownloadUrl(info); + const targetUri = getTargetUri(info); + + await ensureDownloadDirectory(); + await FileSystem.deleteAsync(targetUri, { idempotent: true }); + + const downloadTask = FileSystem.createDownloadResumable( + downloadUrl, + targetUri, + {}, + progressEvent => { + const totalBytes = progressEvent.totalBytesExpectedToWrite; + onProgress?.({ + receivedBytes: progressEvent.totalBytesWritten, + totalBytes, + progress: totalBytes > 0 ? progressEvent.totalBytesWritten / totalBytes : null, + }); + }, + ); + + const result = await downloadTask.downloadAsync(); + if (!result || result.status < 200 || result.status >= 300) { + await FileSystem.deleteAsync(targetUri, { idempotent: true }); + throw new Error('Download aggiornamento non riuscito.'); + } + + return result.uri; +} + +export async function installDownloadedUpdate(fileUri: string): Promise { + if (Platform.OS !== 'android') { + await Linking.openURL(fileUri); + return; + } + + const contentUri = await FileSystem.getContentUriAsync(fileUri); + const result = await IntentLauncher.startActivityAsync(INSTALL_PACKAGE_ACTION, { + data: contentUri, + flags: FLAG_GRANT_READ_URI_PERMISSION, + type: APK_MIME_TYPE, + extra: { + [EXTRA_RETURN_RESULT]: true, + }, + }); + + if (result.resultCode !== IntentLauncher.ResultCode.Success) { + throw new Error(`Installazione APK non completata (resultCode=${result.resultCode}).`); + } +} + +export async function openUpdateReleasePage(info: UpdateInfo): Promise { + await Linking.openURL(info.releaseUrl); +} + +export async function openUnknownSourcesSettings(): Promise { + if (Platform.OS !== 'android') { + return; + } + + await IntentLauncher.startActivityAsync(IntentLauncher.ActivityAction.MANAGE_UNKNOWN_APP_SOURCES, { + data: Application.applicationId ? `package:${Application.applicationId}` : undefined, + }); +} diff --git a/src/widgets/ShiftWidget.tsx b/src/widgets/ShiftWidget.tsx index 6523542..9393ef2 100644 --- a/src/widgets/ShiftWidget.tsx +++ b/src/widgets/ShiftWidget.tsx @@ -152,9 +152,8 @@ function Header({ label }: { label?: string }) { alignItems: 'center', }} > - - - - + + + + + + + ); diff --git a/src/widgets/widgetTaskHandler.tsx b/src/widgets/widgetTaskHandler.tsx index 93e86d9..264a823 100644 --- a/src/widgets/widgetTaskHandler.tsx +++ b/src/widgets/widgetTaskHandler.tsx @@ -3,7 +3,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import type { WidgetTaskHandlerProps } from 'react-native-android-widget'; import type { HexColor } from '../utils/airlineOps'; import { getAirlineOps, getAirlineColor } from '../utils/airlineOps'; -import { getStoredAirportCode, buildFr24ScheduleUrl, getAirportAirlines } from '../utils/airportSettings'; +import { getStoredAirportCode, buildFr24ScheduleUrl, getStoredAirportAirlines, storeDetectedAirportAirlines } from '../utils/airportSettings'; import { ShiftWidget } from './ShiftWidget'; /** Key used by the main app (FlightScreen) to push pre-built widget data */ @@ -100,7 +100,7 @@ async function fetchFreshWidgetData(): Promise { const shiftToday = shiftData.shiftToday; const airportCode = await getStoredAirportCode(); - const allAirlines = getAirportAirlines(airportCode); + const allAirlines = await getStoredAirportAirlines(airportCode); const filterRaw = await AsyncStorage.getItem('aerostaff_flight_filter_v1'); const allowedAirlines: string[] = filterRaw ? JSON.parse(filterRaw) : allAirlines; const url = buildFr24ScheduleUrl(airportCode); @@ -112,6 +112,7 @@ async function fetchFreshWidgetData(): Promise { const res = await fetch(url, { headers: { 'User-Agent': 'Mozilla/5.0' }, signal: controller.signal }); const json = await res.json(); allDepartures = json.result?.response?.airport?.pluginData?.schedule?.departures?.data || []; + await storeDetectedAirportAirlines(airportCode, allDepartures); } finally { clearTimeout(timer); } @@ -120,9 +121,11 @@ async function fetchFreshWidgetData(): Promise { const nowHH = fmtTs(Date.now() / 1000); const shiftLabel = `${fmtTs(shiftToday.start)} – ${fmtTs(shiftToday.end)}`; - const filteredDeps = allDepartures.filter(item => - allowedAirlines.some(key => (item.flight?.airline?.name || '').toLowerCase().includes(key)), - ); + const filteredDeps = allowedAirlines.length === 0 + ? allDepartures + : allDepartures.filter(item => + allowedAirlines.some(key => (item.flight?.airline?.name || '').toLowerCase().includes(key)), + ); const wFlights: WidgetFlight[] = filteredDeps .filter(item => {