diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 01037b4..45b4fad 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -68,7 +68,17 @@ "Bash(gh pr:*)", "Bash(ls /home/gabry848/Documenti/MyTaskly/MyTaskly-app/src/components/*.tsx)", "Bash(python3:*)", - "Bash(but status:*)" + "Bash(but status:*)", + "mcp__claude_ai_Notion__notion-fetch", + "mcp__claude_ai_Notion__notion-search", + "mcp__claude_ai_Notion__notion-update-page", + "Bash(openspec list *)", + "mcp__revenuecat__list-projects", + "mcp__revenuecat__list-apps", + "mcp__revenuecat__list-products", + "mcp__revenuecat__list-offerings", + "mcp__revenuecat__get-product", + "mcp__revenuecat__get-product-store-state" ], "deny": [], "defaultMode": "acceptEdits" diff --git a/openspec/changes/reusable-ui-foundation-plan/.openspec.yaml b/openspec/changes/reusable-ui-foundation-plan/.openspec.yaml new file mode 100644 index 0000000..0a064c1 --- /dev/null +++ b/openspec/changes/reusable-ui-foundation-plan/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-28 diff --git a/openspec/changes/reusable-ui-foundation-plan/audit-ui-matrix.md b/openspec/changes/reusable-ui-foundation-plan/audit-ui-matrix.md new file mode 100644 index 0000000..6ddabcb --- /dev/null +++ b/openspec/changes/reusable-ui-foundation-plan/audit-ui-matrix.md @@ -0,0 +1,23 @@ +## UI Pattern Matrix + +Scope analizzato: `Categories`, `TaskList`, `Calendar`, `Calendar20`, `Home`. + +| Pattern | Categories | TaskList | Calendar | Calendar20 | Home | Opportunity | +|---|---|---|---|---|---|---| +| Screen header | titolo con `Text` custom | usa navigation header + action icon custom | titolo + toggle icon custom | top bar custom | header con title + azioni | estrarre `ScreenHeader` + `IconActionButton` | +| Card/surface | card categoria locali | card task con ombre/radius custom | task list usa `Task` card locale | viste interne con superfici locali | bubble/input surface locali | estrarre `CardSurface` | +| Loading | refresh control native | overlay dots animata custom | `ActivityIndicator` + dots custom | `ActivityIndicator` semplice | loading bubble e indicatori custom | estrarre `LoadingState` (`spinner`,`dots`) | +| Empty state | non standardizzato | blocco icona + testo inline | blocco icona + testo inline | dipende dalla vista | chat pre-start state custom | estrarre `EmptyState` | +| Modal shell | `GlobalTaskSearch` + modali categoria locali | `FilterModal` + `AddTask` | `AddTask` | `ViewSelector`, `SearchOverlay`, `MiniCalendar`, `AddTask` | `VoiceChatModal`, `VoiceCalendarModal` | estrarre `ModalShell` base | +| Input shell | solo search button | task form esterno | task form esterno | task form esterno | input chat duplicato in 2 varianti | estrarre `InputShell` | +| Status/metadata chip | limitato | filtri/stati custom | sync indicator chips inline | category filters/view selector locali | badge e stati locali | estrarre `StatusChip`/`MetaChip` | +| Typography scale | titolo 30/700 | mixed local styles | titolo 30/200 + body custom | varianti locali | grande varianza (display/body/caption) | estrarre `AppText` + token typography | +| Spacing/radii/elevation | numeri hardcoded | numeri hardcoded | numeri hardcoded | numeri hardcoded | numeri hardcoded | estrarre token `spacing`,`radius`,`elevation` | + +## Duplicazioni critiche emerse + +1. Titoli schermata non uniformi (peso/font-size diversi tra screen principali). +2. Loader multipli con logiche simili ma implementazioni diverse. +3. Empty state ripetuti con icona + testo hardcoded. +4. Card/bubble/input surfaces con bordi, radius, ombre ricreate localmente. +5. Chip di stato/sync e metadati implementati in modo ad-hoc. diff --git a/openspec/changes/reusable-ui-foundation-plan/design.md b/openspec/changes/reusable-ui-foundation-plan/design.md new file mode 100644 index 0000000..ceed8aa --- /dev/null +++ b/openspec/changes/reusable-ui-foundation-plan/design.md @@ -0,0 +1,89 @@ +## Context + +Le schermate analizzate (`Categories`, `TaskList`, `Calendar`, `Home`) mostrano una forte duplicazione di pattern UI: +- tipografia hardcoded (`fontSize`, `fontWeight`, `letterSpacing`, `fontFamily: "System"`) con varianti incoerenti; +- container e superfici ripetute (`backgroundColor: "#ffffff"`, ombre, bordi, raggi); +- loader custom multipli (dots animation in `TaskListContainer` e `CalendarView`, `ActivityIndicator` diretto in altri punti); +- shell di input/card/modal implementate localmente con stili simili ma non condivisi. + +L'obiettivo non e riscrivere tutto subito, ma introdurre una base riusabile che riduca i macro-componenti e abiliti refactor incrementale senza regressioni funzionali. + +## Goals / Non-Goals + +**Goals:** +- Definire token di design riutilizzabili per typography, spacing, radius, elevation e colori neutrali. +- Introdurre primitive UI composabili per screen header, card shell, section header, chip, empty state, loading state, modal shell e content container. +- Preparare un piano di migrazione progressivo per `Categories`, `TaskList`, `Calendar` e compatibilita con `Home`. +- Ridurre l'uso di stile inline e stringhe colore/font duplicate nelle schermate target. + +**Non-Goals:** +- Rebranding grafico completo o cambio radicale della visual identity. +- Migrazione completa di tutte le schermate in un'unica PR. +- Sostituzione totale delle librerie di animazione o navigazione esistenti. +- Cambiamenti funzionali ai flussi business (sync, CRUD task, chat logic). + +## Decisions + +### Decisione 1: Introdurre un layer `UI Foundation` in `src/components/UI/foundation` + `src/theme` +**Scelta:** creare moduli condivisi: +- `src/theme/tokens.ts` (spacing, typography scale, radii, elevation, semantic colors), +- `src/theme/primitives.ts` (helper e mapping runtime), +- `src/components/UI/foundation/*` (primitive React Native). + +**Razionale:** separa stile e composizione da feature business, facilita consistenza e testing visuale. + +**Alternative considerate:** +- mantenere style object locali e fare solo cleanup manuale -> scarsa scalabilita; +- usare libreria design-system esterna completa -> overhead alto per stato attuale del progetto. + +### Decisione 2: Definire primitive “thin” e composabili invece di nuovi macro-componenti +**Scelta:** introdurre componenti base a responsabilita singola: +- `ScreenContainer`, `ScreenHeader`, `AppText`, `CardSurface`, `SectionBlock`, `StatusChip`, `EmptyState`, `LoadingState`, `ModalShell`. + +**Razionale:** le schermate restano owner del flusso dati ma delegano il rendering ricorrente; evita monoliti difficili da riusare. + +**Alternative considerate:** +- creare un unico `SmartScreenScaffold` onnicomprensivo -> poco flessibile e rischio coupling. + +### Decisione 3: Loader unificato con varianti +**Scelta:** standardizzare in un singolo `LoadingState` con varianti: +- `spinner`, +- `dots`, +- `skeleton-card` (fase successiva). + +**Razionale:** oggi esistono almeno tre pattern loader diversi; unificando API e animazioni si migliora coerenza e manutenzione. + +**Alternative considerate:** +- lasciare loader per schermata -> UI incoerente e logica animazioni duplicata. + +### Decisione 4: Migrazione per feature slices (Calendar/Task/Categories/Home) +**Scelta:** refactor incrementale per schermata, con fallback semplice (si puo mantenere uno style locale se la primitive non copre un edge case). + +**Razionale:** riduce rischio regressioni e mantiene PR reviewabili. + +**Alternative considerate:** +- big-bang migration completa -> alto rischio conflitti e bug UX. + +## Risks / Trade-offs + +- **[Rischio] Over-abstraction precoce** -> **Mitigazione:** introdurre solo primitive validate da almeno 2 schermate. +- **[Rischio] Regressioni di spacing/typography visive** -> **Mitigazione:** checklist visuale per schermata e verifica manuale su device principali. +- **[Rischio] Team adoption parziale** -> **Mitigazione:** documentare linee guida e usare lint rule/readme per nuovi componenti UI. +- **[Trade-off] Layer aggiuntivo iniziale** -> **Mitigazione:** naming semplice, props minime, esempi pratici in `tasks.md`. + +## Migration Plan + +1. Creare tokens + primitive base senza toccare feature logic. +2. Migrare `Categories` (screen header + container + spacing + action zone). +3. Migrare `TaskList` (loading, section title, empty state, card shell fragments). +4. Migrare `Calendar` e `Calendar20` (header blocks, loading, empty state, chip/sync indicator shell). +5. Rifinire compatibilita con `Home` (header actions, input shell, loading bubble pattern). +6. Rimuovere stili duplicati rimasti e documentare i pattern standard. + +Rollback: mantenere le primitive backward-compatible e migrare file-by-file; eventuale rollback limitato al singolo screen commit. + +## Open Questions + +- Conviene centralizzare anche icon size tokens (es. `icon.sm/md/lg`) nella prima iterazione? +- `Home` richiede un `ChatInputShell` dedicato oppure basta comporre `CardSurface + InputRow + IconButton`? +- Si desidera aggiungere snapshot/UI tests per primitive critiche in questa change o in una successiva? diff --git a/openspec/changes/reusable-ui-foundation-plan/proposal.md b/openspec/changes/reusable-ui-foundation-plan/proposal.md new file mode 100644 index 0000000..8265d2b --- /dev/null +++ b/openspec/changes/reusable-ui-foundation-plan/proposal.md @@ -0,0 +1,27 @@ +## Why + +Le schermate `Categories`, `TaskList`, `Calendar` e `Home` usano pattern visivi simili (titoli, card, loader, vuoti, contenitori, modali) ma implementati in modo diverso e locale. Questo rende difficile mantenere coerenza, velocita di sviluppo e qualita UX quando si aggiungono nuove feature. + +## What Changes + +- Definire una UI foundation condivisa per spacing, tipografia, colori neutrali, raggi, ombre e layout container. +- Introdurre componenti composabili riutilizzabili per header di schermata, card shell, blocchi di metadata, stati vuoti, indicatori di caricamento e shell modali. +- Standardizzare i pattern di interazione comuni (FAB, input shell, chip/badge stato, section title). +- Definire una roadmap di migrazione incrementale partendo da `Categories`, `TaskList`, `Calendar` e compatibilita con `Home`. +- Documentare naming, props minime, e linee guida di adozione per evitare nuovi macro-componenti monolitici. + +## Capabilities + +### New Capabilities +- `ui-foundation-primitives`: token e primitive di base (typography, spacing, colors, radii, elevation, container) con API riusabile in tutta l'app. +- `ui-composable-patterns`: componenti composti ma generici (card, section, loader, empty state, modal shell, header screen) da applicare alle schermate principali. +- `ui-migration-playbook`: piano operativo per migrare le schermate target senza regressioni visive o funzionali. + +### Modified Capabilities +- Nessuna capability esistente da modificare (repository senza spec OpenSpec preesistenti). + +## Impact + +- Aree toccate: `src/components/UI`, `src/components/Task`, `src/components/Category`, `src/components/Calendar`, `src/navigation/screens`. +- Possibile introduzione di nuovi file di stile condiviso (es. `src/theme/*` o `src/components/UI/foundation/*`). +- Riduzione progressiva di stili inline e duplicazioni in componenti specifici di schermata. diff --git a/openspec/changes/reusable-ui-foundation-plan/specs/ui-composable-patterns/spec.md b/openspec/changes/reusable-ui-foundation-plan/specs/ui-composable-patterns/spec.md new file mode 100644 index 0000000..21a1f39 --- /dev/null +++ b/openspec/changes/reusable-ui-foundation-plan/specs/ui-composable-patterns/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: Reusable surface patterns SHALL standardize cards and sections +The system SHALL provide composable surface components (card shell, section block, section header) that standardize borders, radius, elevation, and internal spacing patterns. + +#### Scenario: Card consistency across modules +- **WHEN** `Task`, `Category`, or `Calendar` content is rendered in a boxed surface +- **THEN** the rendered container MUST use the shared card surface pattern instead of independently redefined style objects + +### Requirement: Loading and empty states MUST use shared feedback components +The system MUST provide reusable loading and empty-state components with configurable icon/text and visual variants for list and screen contexts. + +#### Scenario: Unified loading behavior +- **WHEN** a screen enters an initial loading state +- **THEN** it SHALL render a shared loading component variant (`spinner` or `dots`) with standardized spacing and typography + +### Requirement: Modal and action shells SHALL be reusable across features +The system SHALL expose reusable modal shell and action-row patterns to support feature-level modals without duplicating structure and base styling. + +#### Scenario: Modal reuse between features +- **WHEN** a feature opens a modal for data entry or quick actions +- **THEN** the modal MUST be composable through a shared modal shell with configurable header, body, and action slots diff --git a/openspec/changes/reusable-ui-foundation-plan/specs/ui-foundation-primitives/spec.md b/openspec/changes/reusable-ui-foundation-plan/specs/ui-foundation-primitives/spec.md new file mode 100644 index 0000000..b556672 --- /dev/null +++ b/openspec/changes/reusable-ui-foundation-plan/specs/ui-foundation-primitives/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: Shared design tokens SHALL define foundational visual rules +The system SHALL expose a shared token set for spacing, typography, colors, radii, and elevation so that screens can consume consistent values instead of local hardcoded style literals. + +#### Scenario: Token usage in screen styles +- **WHEN** a screen defines layout spacing or text styles +- **THEN** it MUST reference shared tokens for standard values (spacing scale, text roles, semantic colors, radius, shadow/elevation) + +### Requirement: Foundation primitives MUST provide reusable layout and text building blocks +The system MUST provide reusable primitives for common layout and text roles used across task, category, calendar, and home experiences. + +#### Scenario: Primitive coverage for common needs +- **WHEN** a developer builds or refactors a screen section +- **THEN** they SHALL be able to use foundation components for container, heading text, body text, and grouped spacing without introducing new screen-specific base wrappers + +### Requirement: Foundation APIs SHALL remain lightweight and composable +The system SHALL keep primitive component APIs minimal and composable to prevent creation of new macro-components with mixed responsibilities. + +#### Scenario: No monolithic primitive contracts +- **WHEN** a new foundation primitive is introduced +- **THEN** it MUST focus on a single concern (for example surface, typography, spacing, or status tag) and avoid coupling business logic or screen-specific behavior diff --git a/openspec/changes/reusable-ui-foundation-plan/specs/ui-migration-playbook/spec.md b/openspec/changes/reusable-ui-foundation-plan/specs/ui-migration-playbook/spec.md new file mode 100644 index 0000000..43258e2 --- /dev/null +++ b/openspec/changes/reusable-ui-foundation-plan/specs/ui-migration-playbook/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: Migration plan SHALL prioritize target screens in defined order +The system SHALL define and follow a phased migration order covering `Categories`, `TaskList`, and `Calendar` first, with `Home` used as compatibility validation context. + +#### Scenario: Ordered migration execution +- **WHEN** migration tasks are executed +- **THEN** implementation MUST prioritize shared primitives adoption on the target screens before broader rollout + +### Requirement: Migration SHALL preserve existing business behavior +The system SHALL preserve existing screen behavior (task CRUD, sync indicators, chat interactions, navigation) while refactoring visual composition to shared components. + +#### Scenario: Visual refactor without functional regressions +- **WHEN** a screen is refactored to foundation components +- **THEN** user-visible business actions and data flows MUST continue to work equivalently to the pre-migration implementation + +### Requirement: Migration checklist MUST define verification criteria +The system MUST include a per-screen verification checklist for typography, spacing, loading state, empty state, modal shell, and interactive controls after each migration slice. + +#### Scenario: Post-migration validation +- **WHEN** a migration slice is completed for a screen +- **THEN** the team SHALL verify checklist criteria before marking the slice as done diff --git a/openspec/changes/reusable-ui-foundation-plan/tasks.md b/openspec/changes/reusable-ui-foundation-plan/tasks.md new file mode 100644 index 0000000..b5912b1 --- /dev/null +++ b/openspec/changes/reusable-ui-foundation-plan/tasks.md @@ -0,0 +1,54 @@ +## 1. Audit e baseline UI + +- [x] 1.1 Estrarre in una matrice i pattern duplicati nelle schermate `Categories`, `TaskList`, `Calendar`, `Calendar20`, `Home` (header, card, loader, empty, modal, input shell) +- [x] 1.2 Definire naming convention e cartelle target (`src/theme`, `src/components/UI/foundation`) per token e primitive +- [x] 1.3 Creare checklist visuale di validazione per schermata (tipografia, spazi, elevazione, stati di caricamento/vuoto, azioni) + +## 2. Fondazioni (token + primitive) + +- [x] 2.1 Implementare `src/theme/tokens.ts` con scale di spacing, typography roles, colori semantici neutrali, radius, elevation +- [x] 2.2 Implementare `AppText` con varianti (`display`, `title`, `subtitle`, `body`, `caption`, `label`) basate su token +- [x] 2.3 Implementare `ScreenContainer` e `ContentContainer` per sostituire wrapper ripetuti `backgroundColor/padding` +- [x] 2.4 Implementare `ScreenHeader` con supporto titolo, azioni destre, varianti allineamento +- [x] 2.5 Implementare `CardSurface` con varianti (`default`, `outlined`, `interactive`) e supporto accent border + +## 3. Pattern composabili condivisi + +- [x] 3.1 Implementare `SectionHeader` (titolo + action slot + opzionale subtitle) +- [x] 3.2 Implementare `StatusChip`/`MetaChip` per stati task, categoria, sync +- [x] 3.3 Implementare `LoadingState` con varianti `spinner` e `dots` riusabili +- [x] 3.4 Implementare `EmptyState` con icona, titolo, descrizione e CTA opzionale +- [x] 3.5 Implementare `ModalShell` con header/body/footer slot e gestione safe-area +- [x] 3.6 Implementare `InputShell` (row con leading/trailing action e text input) per pattern usato in `Home` + +## 4. Migrazione schermate prioritarie + +- [x] 4.1 Migrare `Categories` a `ScreenContainer + ScreenHeader + ContentContainer` e uniformare spazi e titolo +- [x] 4.2 Migrare componenti categoria principali (`CategoryCard`/vista lista) a `CardSurface` e `SectionHeader` +- [x] 4.3 Migrare `TaskListContainer` a `LoadingState`, `EmptyState`, `SectionHeader`, chip stato/filtro condivisi +- [x] 4.4 Migrare `TaskCard` verso composizione `CardSurface + AppText + MetaChip` mantenendo comportamento corrente +- [x] 4.5 Migrare `CalendarView` a loader/empty/sync chip condivisi e header standardizzato +- [x] 4.6 Verificare `Calendar20View` su container/header coerenti e compatibilita con pattern foundation +- [x] 4.7 Applicare hardening su `Home` (header actions, loading bubble pattern, input shell) senza alterare flussi chat + +## 5. Validazione, cleanup e adozione + +- [x] 5.1 Eseguire smoke test manuale per `Categories`, `TaskList`, `Calendar`, `Home` dopo ogni slice di migrazione +- [x] 5.2 Rimuovere stili duplicati e inline obsolete nelle schermate migrate +- [x] 5.3 Aggiungere documentazione d’uso dei nuovi componenti in `src/components/UI/foundation/README.md` +- [x] 5.4 Definire lista “do/don’t” per evitare nuovi macro-componenti e favorire composizione + +## 6. Lista componenti da creare + +- [x] 6.1 `AppText` +- [x] 6.2 `ScreenContainer` +- [x] 6.3 `ContentContainer` +- [x] 6.4 `ScreenHeader` +- [x] 6.5 `CardSurface` +- [x] 6.6 `SectionHeader` +- [x] 6.7 `StatusChip` / `MetaChip` +- [x] 6.8 `LoadingState` (`spinner`, `dots`) +- [x] 6.9 `EmptyState` +- [x] 6.10 `ModalShell` +- [x] 6.11 `InputShell` +- [x] 6.12 `IconActionButton` diff --git a/openspec/changes/reusable-ui-foundation-plan/visual-validation-checklist.md b/openspec/changes/reusable-ui-foundation-plan/visual-validation-checklist.md new file mode 100644 index 0000000..7cf3257 --- /dev/null +++ b/openspec/changes/reusable-ui-foundation-plan/visual-validation-checklist.md @@ -0,0 +1,34 @@ +## Visual Validation Checklist + +Da usare dopo ogni migrazione slice per `Categories`, `TaskList`, `Calendar`, `Home`. + +## Global + +- [ ] Tipografia coerente con ruoli `display/title/subtitle/body/caption/label`. +- [ ] Spacing coerente con scala token (niente numeri arbitrari fuori scala). +- [ ] Radius/elevation coerenti con varianti standard. +- [ ] Stati focus/press/disabled leggibili e consistenti. + +## Categories + +- [ ] Header title e azioni rispettano pattern `ScreenHeader`. +- [ ] Lista categorie usa superfici con spaziature omogenee. +- [ ] Empty/loading state conformi ai componenti condivisi. + +## TaskList + +- [ ] Header sezione e filtri leggibili e allineati. +- [ ] Task card con gerarchia tipografica consistente. +- [ ] Loader/empty state non usano implementazioni locali duplicate. + +## Calendar + +- [ ] Header data e azioni rispettano pattern condiviso. +- [ ] Sync indicator usa chip/metadati standard. +- [ ] Empty state per data senza task coerente col design system. + +## Home + +- [ ] Header actions con dimensioni/padding consistenti. +- [ ] Input shell chat non duplica stili base. +- [ ] Loading bubble e indicatori rispettano tokens e text roles. diff --git a/openspec/changes/subscription-plans-screen/.openspec.yaml b/openspec/changes/subscription-plans-screen/.openspec.yaml new file mode 100644 index 0000000..863bff1 --- /dev/null +++ b/openspec/changes/subscription-plans-screen/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-17 diff --git a/openspec/changes/subscription-plans-screen/design.md b/openspec/changes/subscription-plans-screen/design.md new file mode 100644 index 0000000..d574f4d --- /dev/null +++ b/openspec/changes/subscription-plans-screen/design.md @@ -0,0 +1,55 @@ +## Context + +MyTaskly has three subscription tiers (free / pro / premium) enforced server-side. RevenueCat is the chosen payment provider and already handles backend webhook events (`POST /billing/webhook`). The client currently has no purchase UI — upgrade buttons show "Coming soon!". `planService.ts` already exposes `getUserPlan()` and `cancelSubscription()`. The `UserSubscription` type already includes all limit fields. + +Current navigation stack (`RootStackParamList`) has no `SubscriptionPlans` entry. Settings and AISettings both have an upgrade `TouchableOpacity` that shows an alert. + +## Goals / Non-Goals + +**Goals:** +- Add `SubscriptionPlans` screen: plan cards with feature comparison + Play Store purchase via RevenueCat +- Wire "Upgrade plan" buttons in Settings and AISettings to navigate to the new screen +- Fetch live prices from RevenueCat; fall back to hardcoded plan data if offline +- Handle purchase, restore, and cancel flows + +**Non-Goals:** +- iOS App Store support (Android/Play Store only in this change) +- Web payment or Stripe integration +- Admin plan override UI +- Custom paywall analytics beyond what RevenueCat provides by default + +## Decisions + +### D1: RevenueCat SDK (`react-native-purchases`) +**Choice**: Use the official `react-native-purchases` package. +**Why**: RevenueCat is already the backend payment provider. The SDK handles Play Store product fetch, purchase sheet, receipt validation, and restore — all with a single `Purchases.purchasePackage()` call. Alternative (raw Google Play Billing) requires far more code and manual receipt validation. + +### D2: New service `revenueCatService.ts` +**Choice**: Encapsulate all RevenueCat SDK calls in `src/services/revenueCatService.ts`. +**Why**: Keeps the screen thin; testable in isolation; consistent with the project's service layer pattern (singleton + lazy init). The screen only calls `getOfferings()`, `purchasePlan()`, `restorePurchases()`. + +### D3: Plan limits as static constants, not fetched from API +**Choice**: Hardcode plan limits in `src/constants/planLimits.ts` mirroring the table in the proposal. +**Why**: The limits are part of the app's value proposition copy — they change rarely and need to be displayed even when offline. `getUserPlan()` provides the *user's current* limits at runtime; the paywall shows the *offered* limits for comparison. + +### D4: `SubscriptionPlans` as a stack screen (not modal/tab) +**Choice**: Add `SubscriptionPlans: undefined` to `RootStackParamList`, navigated via `navigation.navigate('SubscriptionPlans')`. +**Why**: Consistent with the existing navigation pattern (Settings, AISettings, etc.). Modal presentation would require a separate stack; stack screen reuses the existing navigator. + +### D5: RevenueCat product IDs +**Choice**: Map plan tiers to Play Store product IDs via a constant map: +``` +pro → "mytaskly_pro_monthly" +premium → "mytaskly_premium_monthly" +``` +These must match the product IDs configured in Google Play Console and RevenueCat dashboard. + +## Risks / Trade-offs + +| Risk | Mitigation | +|---|---| +| RevenueCat SDK not initialized before screen opens | Initialize in `revenueCatService.ts` `getInstance()` call; catch and surface error in UI | +| Play Store products not configured → empty offerings | Show hardcoded plan cards without price; disable purchase button with "Unavailable" label | +| User purchases but server webhook delayed → plan still shows free | After purchase success, call `getUserPlan()` to refresh and show optimistic "Processing..." state | +| `react-native-purchases` requires native build (no Expo Go support) | App already uses `expo-dev-client`; native build required anyway | +| Cancel flow: `cancelSubscription()` cancels server-side but Play Store auto-renews until period end | Show grace period info (`current_period_end`) in UI after cancel | diff --git a/openspec/changes/subscription-plans-screen/proposal.md b/openspec/changes/subscription-plans-screen/proposal.md new file mode 100644 index 0000000..4e82ba9 --- /dev/null +++ b/openspec/changes/subscription-plans-screen/proposal.md @@ -0,0 +1,45 @@ +## Why + +MyTaskly needs a revenue stream. The backend already enforces plan limits (categories, AI model, daily/monthly chat quotas) but users have no way to upgrade — the app lacks a paywall, plan comparison, and purchase flow. Play Store IAP via RevenueCat is already chosen as the payment provider; this change wires the client end. + +## What Changes + +- New **SubscriptionPlans** screen showing Free / Pro / Premium plan cards with feature comparison +- RevenueCat SDK (`react-native-purchases`) integrated for Play Store product fetch and purchase +- Purchase flow: tap upgrade → Google Play sheet → confirm → subscription active +- Cancel subscription UI via `POST /billing/cancel` (already implemented in `planService.ts`) +- Plan limits shown per card using the table below (hardcoded as fallback if RevenueCat offline): + +| Feature | Free | Pro | Premium | +|---|---|---|---| +| Chat text — daily | 20 | 50 | ∞ | +| Chat text — monthly | 130 | 250 | 400 | +| Chat voice — daily | ∞ | ∞ | ∞ | +| Chat voice — monthly | 20 | 50 | 150 | +| AI model | base | advanced | advanced | +| Categories | max 5 | ∞ | ∞ | + +- Navigation entry point: "View plan" button already present in Settings and AISettings +- Localization: IT + EN + +## Capabilities + +### New Capabilities + +- `subscription-plans-screen`: Dedicated screen for plan comparison and purchase via RevenueCat/Play Store +- `revenuecat-integration`: RevenueCat SDK setup, product fetch, purchase and restore flows + +### Modified Capabilities + +- `plan-display`: Settings and AISettings currently show plan badge + limits inline; the "Upgrade" button now navigates to SubscriptionPlans instead of showing "Coming soon!" + +## Impact + +- New dependency: `react-native-purchases` (RevenueCat SDK) +- New screen: `src/navigation/screens/SubscriptionPlans.tsx` +- New service: `src/services/revenueCatService.ts` +- Navigation: `RootStackParamList` gains `SubscriptionPlans` entry +- `Settings.tsx` and `AISettings.tsx`: upgrade button wired to navigation +- `planService.ts`: already has `cancelSubscription()` — no new API calls needed +- `app.json`: may need RevenueCat plugin entry +- No backend changes needed — plan activation is handled server-side via RevenueCat webhook (already implemented) diff --git a/openspec/changes/subscription-plans-screen/specs/plan-display/spec.md b/openspec/changes/subscription-plans-screen/specs/plan-display/spec.md new file mode 100644 index 0000000..bb44464 --- /dev/null +++ b/openspec/changes/subscription-plans-screen/specs/plan-display/spec.md @@ -0,0 +1,16 @@ +## MODIFIED Requirements + +### Requirement: Upgrade button navigates to SubscriptionPlans +In Settings and AISettings, the "Upgrade plan" button for free users SHALL navigate to the `SubscriptionPlans` screen instead of showing a "Coming soon!" alert. + +#### Scenario: Free user taps upgrade in Settings +- **WHEN** user with `effective_plan = "free"` taps the upgrade button in Settings +- **THEN** `navigation.navigate('SubscriptionPlans')` is called + +#### Scenario: Free user taps upgrade in AISettings +- **WHEN** user with `effective_plan = "free"` taps the upgrade button in AISettings +- **THEN** `navigation.navigate('SubscriptionPlans')` is called + +#### Scenario: Non-free user sees no upgrade button +- **WHEN** user has `effective_plan != "free"` +- **THEN** the upgrade button is not rendered in Settings or AISettings plan card diff --git a/openspec/changes/subscription-plans-screen/specs/revenuecat-integration/spec.md b/openspec/changes/subscription-plans-screen/specs/revenuecat-integration/spec.md new file mode 100644 index 0000000..af9d31a --- /dev/null +++ b/openspec/changes/subscription-plans-screen/specs/revenuecat-integration/spec.md @@ -0,0 +1,54 @@ +## ADDED Requirements + +### Requirement: RevenueCat SDK initialized on first use +`revenueCatService.ts` SHALL initialize `Purchases` with the RevenueCat API key on first `getInstance()` call. Initialization SHALL be idempotent (safe to call multiple times). + +#### Scenario: First initialization +- **WHEN** `RevenueCatService.getInstance()` is called for the first time +- **THEN** `Purchases.configure({ apiKey })` is called once with the Android public API key + +#### Scenario: Re-initialization guard +- **WHEN** `getInstance()` is called a second time +- **THEN** `Purchases.configure()` is NOT called again + +### Requirement: Offerings fetched from RevenueCat +The service SHALL expose `getOfferings()` returning the current RevenueCat offerings. SHALL return `null` (not throw) if fetch fails, so callers can handle offline gracefully. + +#### Scenario: Offerings available +- **WHEN** `getOfferings()` is called and RevenueCat responds +- **THEN** the current offering object is returned + +#### Scenario: Offerings fetch fails +- **WHEN** `getOfferings()` is called and network/SDK error occurs +- **THEN** `null` is returned (no uncaught exception) + +### Requirement: Purchase flow via RevenueCat package +The service SHALL expose `purchasePlan(packageToPurchase)` that calls `Purchases.purchasePackage()` and returns the updated `CustomerInfo`. SHALL re-throw errors so the caller can distinguish user-cancelled from actual errors. + +#### Scenario: Purchase completes +- **WHEN** `purchasePlan(pkg)` is called and user completes Play Store sheet +- **THEN** `CustomerInfo` is returned with updated entitlements + +#### Scenario: Purchase user-cancelled +- **WHEN** user dismisses the Play Store sheet +- **THEN** a `PurchasesError` with `userCancelled = true` is thrown + +### Requirement: Restore purchases +The service SHALL expose `restorePurchases()` that calls `Purchases.restorePurchases()` and returns updated `CustomerInfo`. + +#### Scenario: Restore succeeds +- **WHEN** `restorePurchases()` is called and a prior receipt exists +- **THEN** updated `CustomerInfo` with entitlements is returned + +#### Scenario: Restore finds nothing +- **WHEN** `restorePurchases()` is called with no prior receipt +- **THEN** `CustomerInfo` with empty entitlements is returned (no throw) + +### Requirement: Product ID mapping +A constant map `PLAN_PRODUCT_IDS` SHALL map plan tier to Play Store product ID: +- `pro` → `"mytaskly_pro_monthly"` +- `premium` → `"mytaskly_premium_monthly"` + +#### Scenario: Product ID lookup +- **WHEN** code needs the product ID for tier "pro" +- **THEN** `PLAN_PRODUCT_IDS["pro"]` returns `"mytaskly_pro_monthly"` diff --git a/openspec/changes/subscription-plans-screen/specs/subscription-plans-screen/spec.md b/openspec/changes/subscription-plans-screen/specs/subscription-plans-screen/spec.md new file mode 100644 index 0000000..5f5a81a --- /dev/null +++ b/openspec/changes/subscription-plans-screen/specs/subscription-plans-screen/spec.md @@ -0,0 +1,56 @@ +## ADDED Requirements + +### Requirement: Screen shows plan comparison cards +The screen SHALL display three plan cards (Free, Pro, Premium) each showing: plan name, price (from RevenueCat or "—" if unavailable), and the feature comparison table (daily text messages, monthly text messages, monthly voice messages, AI model, max categories). + +#### Scenario: Plans rendered on open +- **WHEN** user navigates to SubscriptionPlans +- **THEN** three cards are visible: Free, Pro, Premium with feature rows matching the plan limits table + +#### Scenario: Current plan highlighted +- **WHEN** the user's `effective_plan` is "pro" +- **THEN** the Pro card is visually marked as "Current plan" and its purchase button is disabled + +### Requirement: User can purchase a paid plan +The screen SHALL allow users to initiate a Play Store subscription purchase by tapping the upgrade button on a Pro or Premium card. + +#### Scenario: Successful purchase +- **WHEN** user taps "Upgrade to Pro" and completes the Google Play purchase sheet +- **THEN** `getUserPlan()` is called to refresh plan state and a success message is shown + +#### Scenario: Purchase cancelled by user +- **WHEN** user dismisses the Google Play purchase sheet +- **THEN** no error is shown and the screen remains in its previous state + +#### Scenario: Purchase error +- **WHEN** the purchase fails (network error, billing unavailable) +- **THEN** an alert is shown with the error message and the purchase button re-enables + +### Requirement: User can cancel active subscription +The screen SHALL show a "Cancel subscription" option for users with `status = active` or `status = cancelled` (still in grace period). + +#### Scenario: Cancel initiated +- **WHEN** user taps "Cancel subscription" and confirms the confirmation dialog +- **THEN** `cancelSubscription()` is called; on success the plan card updates to show `status = cancelled` with `current_period_end` + +#### Scenario: Cancel on already-cancelled plan +- **WHEN** user has `status = cancelled` +- **THEN** "Cancel subscription" button is hidden; grace period end date is shown instead + +### Requirement: User can restore purchases +The screen SHALL include a "Restore purchases" button that re-validates existing Play Store receipts. + +#### Scenario: Restore with valid receipt +- **WHEN** user taps "Restore purchases" and has an active Play Store subscription +- **THEN** subscription is re-activated, plan refreshed, success message shown + +#### Scenario: Restore with no receipt +- **WHEN** user taps "Restore purchases" and has no prior purchase +- **THEN** alert shown: "No purchases found to restore" + +### Requirement: Offline / unavailable state +When RevenueCat offerings cannot be fetched, the screen SHALL still display the plan cards with hardcoded feature limits but without prices, and purchase buttons SHALL be disabled with label "Unavailable". + +#### Scenario: RevenueCat offline +- **WHEN** `getOfferings()` throws or returns null +- **THEN** plan cards render without prices; purchase buttons show "Unavailable" and are non-tappable diff --git a/openspec/changes/subscription-plans-screen/tasks.md b/openspec/changes/subscription-plans-screen/tasks.md new file mode 100644 index 0000000..4b03ea4 --- /dev/null +++ b/openspec/changes/subscription-plans-screen/tasks.md @@ -0,0 +1,43 @@ +## 1. Dependencies & Constants + +- [x] 1.1 Install `react-native-purchases` and run `npx expo install` +- [x] 1.2 Add RevenueCat plugin to `app.json` if required by the SDK +- [x] 1.3 Create `src/constants/planLimits.ts` with the plan limits table (free/pro/premium) and `PLAN_PRODUCT_IDS` map + +## 2. RevenueCat Service + +- [x] 2.1 Create `src/services/revenueCatService.ts` with singleton pattern +- [x] 2.2 Implement `configure(apiKey)` called once from `getInstance()` +- [x] 2.3 Implement `getOfferings(): Promise` — returns null on error +- [x] 2.4 Implement `purchasePlan(pkg: PurchasesPackage): Promise` — re-throws on error +- [x] 2.5 Implement `restorePurchases(): Promise` + +## 3. Navigation + +- [x] 3.1 Add `SubscriptionPlans: undefined` to `RootStackParamList` in `src/navigation/index.tsx` +- [x] 3.2 Register `SubscriptionPlans` screen in the Stack navigator + +## 4. SubscriptionPlans Screen + +- [x] 4.1 Create `src/navigation/screens/SubscriptionPlans.tsx` scaffold (SafeAreaView, ScrollView, header) +- [x] 4.2 Fetch current user plan (`getUserPlan`) and RevenueCat offerings on mount +- [x] 4.3 Render three plan cards (Free, Pro, Premium) with hardcoded limits from `planLimits.ts` +- [x] 4.4 Display live price from RevenueCat package; show "—" if offering unavailable +- [x] 4.5 Highlight current plan card; disable its purchase button with "Current plan" label +- [x] 4.6 Implement purchase button handler: call `purchasePlan()`, refresh plan on success, show alert on error +- [x] 4.7 Handle user-cancelled purchase (no error shown) +- [x] 4.8 Add "Cancel subscription" button for `status = active`; show `current_period_end` for `status = cancelled` +- [x] 4.9 Implement cancel flow: confirmation dialog → `cancelSubscription()` → refresh plan state +- [x] 4.10 Add "Restore purchases" button → `restorePurchases()` → success/no-purchases-found alert +- [x] 4.11 Add loading state during purchase / restore / cancel operations +- [x] 4.12 Add offline/unavailable state: purchase buttons disabled with "Unavailable" label + +## 5. Wire Upgrade Buttons + +- [x] 5.1 In `Settings.tsx`: replace upgrade `Alert.alert('Coming soon!')` with `navigation.navigate('SubscriptionPlans')` +- [x] 5.2 In `AISettings.tsx`: same replacement for upgrade button + +## 6. Localization + +- [x] 6.1 Add all new strings to `src/locales/en.json` under `subscriptionPlans` namespace +- [x] 6.2 Add Italian translations to `src/locales/it.json` diff --git a/package-lock.json b/package-lock.json index ca8d9ba..dbbb297 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,6 +67,7 @@ "react-native-keyboard-aware-scrollview": "^2.1.0", "react-native-markdown-display": "^7.0.2", "react-native-modal-datetime-picker": "^18.0.0", + "react-native-purchases": "^10.0.1", "react-native-reanimated": "~3.17.4", "react-native-safe-area-context": "5.4.0", "react-native-screens": "~4.11.1", @@ -4866,6 +4867,27 @@ "react-native-screens": ">= 4.0.0" } }, + "node_modules/@revenuecat/purchases-js": { + "version": "1.34.0", + "resolved": "https://registry.npmjs.org/@revenuecat/purchases-js/-/purchases-js-1.34.0.tgz", + "integrity": "sha512-p3hpHHvyllAckqnjpjaCoj+lVK0gNJwqR1F8EwZPw7eMKFummE0UItbGzBCfncJIPxGA1NJHrgZb1dLflEmjhg==", + "license": "MIT" + }, + "node_modules/@revenuecat/purchases-js-hybrid-mappings": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/@revenuecat/purchases-js-hybrid-mappings/-/purchases-js-hybrid-mappings-18.1.0.tgz", + "integrity": "sha512-P2KtqjPUcdEhTx51nzXAWNFoNTRzQm9lR/g2S90jQ/uzwrB4H7OJNRJ9B1NFNa511Uc84LfwmmIh51oeQO9+qQ==", + "license": "MIT", + "dependencies": { + "@revenuecat/purchases-js": "1.34.0" + } + }, + "node_modules/@revenuecat/purchases-typescript-internal": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/@revenuecat/purchases-typescript-internal/-/purchases-typescript-internal-18.1.0.tgz", + "integrity": "sha512-aoXmrvDSCVStXAbv+yfUkb1BEIga2hXYiUSLHOoGMX6luXXlSLNMeG7nhvMa7gXTgjnEZXDYOG59en4NNBtJ6A==", + "license": "MIT" + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -15608,6 +15630,31 @@ "react-native": "*" } }, + "node_modules/react-native-purchases": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/react-native-purchases/-/react-native-purchases-10.0.1.tgz", + "integrity": "sha512-FyJgOLuGo2TqR/sswzgkUebiYu30FPAsB9N2goyv4q2pr5ixPL0v6QrETrYDpnPI8kz/wg+aMv8/HsAU9b6ajw==", + "license": "MIT", + "workspaces": [ + "examples/purchaseTesterTypescript", + "react-native-purchases-ui", + "e2e-tests/MaestroTestApp" + ], + "dependencies": { + "@revenuecat/purchases-js-hybrid-mappings": "18.1.0", + "@revenuecat/purchases-typescript-internal": "18.1.0" + }, + "peerDependencies": { + "react": ">= 16.6.3", + "react-native": ">= 0.73.0", + "react-native-web": "*" + }, + "peerDependenciesMeta": { + "react-native-web": { + "optional": true + } + } + }, "node_modules/react-native-reanimated": { "version": "3.17.5", "resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.17.5.tgz", diff --git a/package.json b/package.json index 302d873..56c30ad 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "react-native-keyboard-aware-scrollview": "^2.1.0", "react-native-markdown-display": "^7.0.2", "react-native-modal-datetime-picker": "^18.0.0", + "react-native-purchases": "^10.0.1", "react-native-reanimated": "~3.17.4", "react-native-safe-area-context": "5.4.0", "react-native-screens": "~4.11.1", diff --git a/src/components/BotChat/VoiceChatModal.tsx b/src/components/BotChat/VoiceChatModal.tsx index 8e0a29b..ee3d1a3 100644 --- a/src/components/BotChat/VoiceChatModal.tsx +++ b/src/components/BotChat/VoiceChatModal.tsx @@ -243,6 +243,7 @@ const VoiceChatModal: React.FC = ({ activeTools, isMuted, isVoiceQuotaExceeded, + isVoiceMonthlyLimitReached, connect, disconnect, requestPermissions, @@ -385,6 +386,31 @@ const VoiceChatModal: React.FC = ({ } }, [isVoiceQuotaExceeded, visible]); // eslint-disable-line react-hooks/exhaustive-deps + // Show monthly limit alert when monthly voice quota is exhausted + useEffect(() => { + if (isVoiceMonthlyLimitReached && visible) { + Alert.alert( + t('planUsage.voiceMonthlyLimitTitle'), + t('planUsage.voiceMonthlyLimitExceeded'), + [ + { + text: t('common.buttons.cancel'), + style: 'cancel', + onPress: handleClose, + }, + { + text: t('planUsage.goToPlan'), + onPress: () => { + handleClose(); + navigation.navigate('Settings'); + }, + }, + ], + { cancelable: false } + ); + } + }, [isVoiceMonthlyLimitReached, visible]); // eslint-disable-line react-hooks/exhaustive-deps + // Label testo stato const getStateLabel = (): string => { switch (state) { diff --git a/src/components/Calendar/CalendarView.tsx b/src/components/Calendar/CalendarView.tsx index 74d8552..484ff0b 100644 --- a/src/components/Calendar/CalendarView.tsx +++ b/src/components/Calendar/CalendarView.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; -import { View, Text, ScrollView, StyleSheet, Alert, ActivityIndicator, Animated, Dimensions } from 'react-native'; +import { View, ScrollView, StyleSheet, Alert, ActivityIndicator, Dimensions } from 'react-native'; import dayjs from 'dayjs'; import { Task as TaskType, getAllTasks, addTask, deleteTask, updateTask, completeTask, disCompleteTask } from '../../services/taskService'; import { TaskCacheService } from '../../services/TaskCacheService'; @@ -13,6 +13,7 @@ import Task from '../Task/Task'; import AddTask from '../Task/AddTask'; import AddTaskButton from '../Task/AddTaskButton'; import { addTaskToList } from '../TaskList/types'; +import { LoadingState, EmptyState, StatusChip, AppText } from '../UI/foundation'; const CalendarView: React.FC = () => { const [selectedDate, setSelectedDate] = useState(dayjs().format('YYYY-MM-DD')); @@ -29,11 +30,6 @@ const CalendarView: React.FC = () => { const syncManager = useRef(SyncManager.getInstance()).current; const appInitializer = useRef(AppInitializer.getInstance()).current; - // Animazioni per i punti di caricamento - const fadeAnim1 = useRef(new Animated.Value(0.3)).current; - const fadeAnim2 = useRef(new Animated.Value(0.3)).current; - const fadeAnim3 = useRef(new Animated.Value(0.3)).current; - // Funzione per sanitizzare le stringhe const sanitizeString = (value: any): string => { if (typeof value === 'string') { @@ -400,55 +396,6 @@ const CalendarView: React.FC = () => { } }; - // Componente di caricamento - const LoadingComponent = () => { - // Avvia l'animazione dei punti quando il componente viene montato - useEffect(() => { - const animateSequence = () => { - const duration = 600; - const delay = 200; - - const animate = (animValue: Animated.Value, startDelay: number) => { - Animated.loop( - Animated.sequence([ - Animated.timing(animValue, { - toValue: 1, - duration: duration, - delay: startDelay, - useNativeDriver: true, - }), - Animated.timing(animValue, { - toValue: 0.3, - duration: duration, - useNativeDriver: true, - }), - ]) - ).start(); - }; - - animate(fadeAnim1, 0); - animate(fadeAnim2, delay); - animate(fadeAnim3, delay * 2); - }; - - animateSequence(); - }, []); - - return ( - - - - Caricamento impegni... - - - - - - - - ); - }; - if (isLoading) { return ( @@ -464,15 +411,15 @@ const CalendarView: React.FC = () => { {/* Header con effetto di caricamento e indicatore sync */} - + Impegni del {dayjs(selectedDate).format('DD MMMM YYYY')} - + {/* Componente di caricamento */} - + {/* Componente AddTask */} { {/* Intestazione con titolo, indicatori sync e pulsante per aggiungere task */} - + Impegni del {dayjs(selectedDate).format('DD MMMM YYYY')} - - {syncStatus && ( + + {syncStatus && (syncStatus.isSyncing || syncStatus.pendingChanges > 0) && ( {syncStatus.isSyncing ? ( - - - Sync... - - ) : !syncStatus.isOnline ? ( - - - Offline - + } + /> ) : syncStatus.pendingChanges > 0 ? ( - - - {syncStatus.pendingChanges} - + } + /> ) : null} )} @@ -541,13 +485,11 @@ const CalendarView: React.FC = () => { /> )) ) : ( - - - - Nessun impegno per questa data - - - + } + title="Nessun impegno per questa data" + style={styles.noTasksContainer} + /> )} @@ -573,8 +515,8 @@ const styles = StyleSheet.create({ backgroundColor: "#ffffff", }, selectedDateHeader: { - marginTop: 20, - marginBottom: 10, + marginTop: 4, + marginBottom: 4, flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', @@ -585,78 +527,17 @@ const styles = StyleSheet.create({ flexDirection: 'column', alignItems: 'flex-start', }, - selectedDateTitle: { - fontSize: 18, - fontWeight: "300", - color: "#000000", - fontFamily: "System", - letterSpacing: -0.5, - }, syncIndicator: { marginTop: 4, flexDirection: 'row', alignItems: 'center', }, - syncingContainer: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: '#f0f0f0', - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 12, - }, - syncText: { - fontSize: 12, - color: '#666666', - marginLeft: 4, - fontFamily: 'System', - }, - offlineContainer: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: '#ffebee', - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 12, - }, - offlineText: { - fontSize: 12, - color: '#ff6b6b', - marginLeft: 4, - fontFamily: 'System', - }, - pendingContainer: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: '#fff3e0', - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 12, - }, - pendingText: { - fontSize: 12, - color: '#ffa726', - marginLeft: 4, - fontFamily: 'System', - }, taskList: { flex: 1, paddingHorizontal: 5, }, noTasksContainer: { - alignItems: "center", - justifyContent: "center", marginTop: 60, - paddingHorizontal: 20, - }, - noTasksText: { - fontSize: 16, - color: "#999999", - marginTop: 15, - marginBottom: 25, - textAlign: "center", - fontFamily: "System", - fontWeight: "300", }, addButton: { backgroundColor: "#000000", @@ -689,36 +570,6 @@ const styles = StyleSheet.create({ fontSize: 15, fontFamily: "System", }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - paddingHorizontal: 20, - }, - loadingContent: { - alignItems: 'center', - justifyContent: 'center', - }, - loadingText: { - fontSize: 16, - color: "#666666", - marginTop: 20, - fontFamily: "System", - fontWeight: "300", - letterSpacing: -0.2, - }, - loadingDots: { - flexDirection: 'row', - marginTop: 15, - alignItems: 'center', - }, - dot: { - width: 8, - height: 8, - borderRadius: 4, - backgroundColor: "#000000", - marginHorizontal: 3, - }, }); export default CalendarView; \ No newline at end of file diff --git a/src/components/Calendar20/Calendar20View.tsx b/src/components/Calendar20/Calendar20View.tsx index 1400fe7..83a07fc 100644 --- a/src/components/Calendar20/Calendar20View.tsx +++ b/src/components/Calendar20/Calendar20View.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; -import { View, StyleSheet, ActivityIndicator } from 'react-native'; +import { View, StyleSheet } from 'react-native'; import dayjs from 'dayjs'; import isoWeek from 'dayjs/plugin/isoWeek'; import { Task, getAllTasks, getCategories, completeTask, disCompleteTask } from '../../services/taskService'; @@ -21,6 +21,7 @@ import SearchOverlay from './SearchOverlay'; import FABMenu from './FABMenu'; import AddTask from '../Task/AddTask'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { LoadingState } from '../UI/foundation'; dayjs.extend(isoWeek); @@ -347,8 +348,8 @@ const Calendar20View: React.FC = ({ onClose }) => { if (isLoading) { return ( - - + + ); } @@ -427,12 +428,6 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#ffffff', }, - loadingContainer: { - flex: 1, - justifyContent: 'center', - alignItems: 'center', - backgroundColor: '#ffffff', - }, }); export default Calendar20View; diff --git a/src/components/Category/AddCategoryButton.tsx b/src/components/Category/AddCategoryButton.tsx index 3831096..08d9543 100644 --- a/src/components/Category/AddCategoryButton.tsx +++ b/src/components/Category/AddCategoryButton.tsx @@ -15,7 +15,7 @@ import Animated, { useAnimatedStyle, withSpring, } from "react-native-reanimated"; -import { addCategory } from "../../services/taskService"; +import { addCategory, CategoryLimitError } from "../../services/taskService"; import { emitCategoryAdded } from "../../utils/eventEmitter"; // Definiamo un'interfaccia chiara per i dati della categoria @@ -106,6 +106,13 @@ const AddCategoryButton: React.FC = ({ onCategoryAdded(newCategory); } } catch (error) { + if (error instanceof CategoryLimitError) { + Alert.alert( + "Limite categorie raggiunto", + "Hai raggiunto il numero massimo di categorie per il tuo piano. Fai l'upgrade per aggiungerne altre." + ); + return; + } console.error("Errore nel salvare la categoria sul server:", error); // In caso di errore del server, aggiungiamo comunque la categoria localmente console.log( diff --git a/src/components/Category/CategoryCard.tsx b/src/components/Category/CategoryCard.tsx index f716ba3..36d7249 100644 --- a/src/components/Category/CategoryCard.tsx +++ b/src/components/Category/CategoryCard.tsx @@ -1,11 +1,12 @@ import React from "react"; -import { StyleSheet, Pressable, Animated, Dimensions, View, Platform } from "react-native"; +import { StyleSheet, Animated, Dimensions, View } from "react-native"; import { useNavigation } from "@react-navigation/native"; import { StackNavigationProp } from '@react-navigation/stack'; import { RootStackParamList } from '../../types'; import CategoryHeader from './CategoryHeader'; import AddTaskButton from '../Task/AddTaskButton'; import SharingInfo from './SharingInfo'; +import { CardSurface } from "../UI/foundation"; type CategoryScreenNavigationProp = StackNavigationProp< RootStackParamList, @@ -87,20 +88,20 @@ const CategoryCard: React.FC = ({ return ( - [ + style={[ styles.view, { marginHorizontal: screenWidth < 350 ? 8 : 16, padding: screenWidth < 350 ? 12 : 16, flexDirection: screenWidth < 320 ? 'column' : 'row', }, - pressed && styles.pressed, ]} + variant="interactive" onPressIn={handlePressIn} onPressOut={handlePressOut} onLongPress={onLongPress} @@ -123,7 +124,7 @@ const CategoryCard: React.FC = ({ badgeType={badgeType} /> - {(isShared || !isOwned) && ( + {!isOwned && ( = ({ {!(isShared && !isOwned && permissionLevel === 'READ_ONLY') && ( )} - + ); }; @@ -146,28 +147,7 @@ const styles = StyleSheet.create({ flexDirection: "row", justifyContent: "space-between", alignItems: "flex-start", - padding: 16, - marginHorizontal: 16, marginVertical: 8, - backgroundColor: "#FFFFFF", - borderRadius: 16, - borderWidth: 1.5, - borderColor: "#E1E5E9", - ...Platform.select({ - ios: { - shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.08, - shadowRadius: 12, - }, - android: { - elevation: 3, - }, - }), - }, - pressed: { - backgroundColor: "#F9F9F9", - borderColor: "#BDBDBD", }, contentContainer: { flex: 1, diff --git a/src/components/Category/CategoryView.tsx b/src/components/Category/CategoryView.tsx index 8bcdfdc..fa32c44 100644 --- a/src/components/Category/CategoryView.tsx +++ b/src/components/Category/CategoryView.tsx @@ -1,27 +1,29 @@ import React, { useState, useEffect, + useRef, forwardRef, useImperativeHandle, + useCallback, } from "react"; import { View, Text, - ScrollView, TouchableOpacity, StyleSheet, Dimensions, + Animated, + Platform, } from "react-native"; -import Icon from "react-native-vector-icons/MaterialIcons"; import { useNavigation, NavigationProp, - useFocusEffect, } from "@react-navigation/native"; import { RootStackParamList } from "../../types"; import { getCategories } from "../../services/taskService"; import Category from "./Category"; +import { SectionHeader } from "../UI/foundation"; export interface CategoryType { id: string | number; @@ -30,10 +32,9 @@ export interface CategoryType { imageUrl?: string; category_id?: number; status_code?: number; - // Campi per la condivisione is_shared?: boolean; owner_id?: number; - owner_name?: string; // Nome del proprietario (per categorie condivise) + owner_name?: string; is_owned?: boolean; permission_level?: "READ_ONLY" | "READ_WRITE"; } @@ -50,6 +51,118 @@ export interface CategoryViewRef { hardReload: () => void; } +// ── Animated wrapper for each category card ── +const CARD_STAGGER = 50; +const CARD_DURATION = 400; + +const AnimatedCard: React.FC<{ + index: number; + isVisible: boolean; + children: React.ReactNode; +}> = ({ index, isVisible, children }) => { + const fade = useRef(new Animated.Value(0)).current; + const slideY = useRef(new Animated.Value(20)).current; + + useEffect(() => { + if (isVisible) { + Animated.parallel([ + Animated.timing(fade, { + toValue: 1, + duration: CARD_DURATION, + delay: index * CARD_STAGGER, + useNativeDriver: true, + }), + Animated.timing(slideY, { + toValue: 0, + duration: CARD_DURATION, + delay: index * CARD_STAGGER, + useNativeDriver: true, + }), + ]).start(); + } else { + // Reset without animation so next entrance animates in + fade.setValue(0); + slideY.setValue(20); + } + }, [isVisible, index]); + + return ( + + {children} + + ); +}; + +// ── Skeleton placeholder cards ── +const SkeletonCard: React.FC<{ index: number }> = ({ index }) => { + const shimmer = useRef(new Animated.Value(0)).current; + + useEffect(() => { + const loop = Animated.loop( + Animated.sequence([ + Animated.timing(shimmer, { + toValue: 1, + duration: 1200, + delay: index * 80, + useNativeDriver: true, + }), + Animated.timing(shimmer, { + toValue: 0, + duration: 1200, + useNativeDriver: true, + }), + ]) + ); + loop.start(); + return () => loop.stop(); + }, [index, shimmer]); + + const translateX = shimmer.interpolate({ + inputRange: [0, 1], + outputRange: [-300, 300], + }); + + const screenWidth = Dimensions.get("window").width; + const mx = screenWidth < 350 ? 8 : 16; + + return ( + + + {/* Avatar circle */} + + {/* Text lines */} + + + + + + {/* Shimmer overlay */} + + + ); +}; + +const SKELETON_COUNT = 4; + +// ── Main CategoryView ── const CategoryView = forwardRef( ( { onCategoryAdded, onCategoryDeleted, onCategoryEdited, reloadCategories }, @@ -60,13 +173,13 @@ const CategoryView = forwardRef( const [loading, setLoading] = useState(true); const [isReloading, setIsReloading] = useState(false); const previousCategoriesRef = React.useRef([]); + const animKeyRef = useRef(0); const categoriesAreEqual = ( cat1: CategoryType[], cat2: CategoryType[] ): boolean => { if (cat1.length !== cat2.length) return false; - return cat1.every((c1) => cat2.some( (c2) => @@ -82,6 +195,7 @@ const CategoryView = forwardRef( forceRefresh: boolean = false, silent: boolean = false ) => { + const startedAt = Date.now(); if (!silent) { setLoading(true); } @@ -98,61 +212,83 @@ const CategoryView = forwardRef( } catch (error) { console.error("Errore nel recupero delle categorie:", error); } finally { - setLoading(false); + if (!silent) { + const elapsed = Date.now() - startedAt; + const remaining = Math.max(0, 500 - elapsed); + if (remaining > 0) { + setTimeout(() => setLoading(false), remaining); + } else { + setLoading(false); + } + } } }; - const hardReload = async () => { + const hardReload = useCallback(async () => { + const startedAt = Date.now(); setIsReloading(true); - setCategories([]); // Nasconde tutte le categorie + animKeyRef.current += 1; + setCategories([]); try { - const categoriesData = await getCategories(false); // Force refresh + const categoriesData = await getCategories(false); if (Array.isArray(categoriesData)) { - // Confronta le categorie con le precedenti - if ( - !categoriesAreEqual(categoriesData, previousCategoriesRef.current) - ) { + if (!categoriesAreEqual(categoriesData, previousCategoriesRef.current)) { setCategories(categoriesData); previousCategoriesRef.current = categoriesData; - console.log("Categorie aggiornate - differenze rilevate"); } else { - // Se non ci sono differenze, mostra comunque le categorie setCategories(categoriesData); - console.log("Categorie ricaricate - nessuna differenza"); } } else { - console.error( - "getCategories non ha restituito un array:", - categoriesData - ); + console.error("getCategories non ha restituito un array:", categoriesData); } } catch (error) { console.error("Errore nel ricaricamento delle categorie:", error); - // Ripristina le categorie precedenti in caso di errore setCategories(previousCategoriesRef.current); } finally { - setIsReloading(false); + const elapsed = Date.now() - startedAt; + const minDisplay = 400; + const remaining = Math.max(0, minDisplay - elapsed); + if (remaining > 0) { + setTimeout(() => setIsReloading(false), remaining); + } else { + setIsReloading(false); + } } - }; + }, []); useEffect(() => { fetchCategories(); }, []); - // Esponi fetchCategories e hardReload tramite ref useImperativeHandle(ref, () => ({ fetchCategories, hardReload, })); + const showCards = !loading && !isReloading && categories.length > 0; + const showEmpty = !loading && !isReloading && categories.length === 0; + return ( - - {/* Mostra le categorie solo se non stiamo ricaricando */} - {!isReloading && categories && categories.length > 0 - ? categories.map((category, index) => { - const categoryElement = ( + + {/* Skeleton loading */} + {(loading || isReloading) && ( + + {Array.from({ length: SKELETON_COUNT }).map((_, i) => ( + + ))} + + )} + + {/* Category cards with staggered entrance */} + {showCards && ( + + {categories.map((category, index) => ( + ( onDelete={onCategoryDeleted} onEdit={onCategoryEdited} /> - ); - - return categoryElement; - }) - : !loading && - !isReloading && ( - - - Aggiungi la tua prima categoria per iniziare!{"\n"} - - - oppure{"\n"} - - { - navigation.navigate("Login"); - }} - > - Vai al login - - - )} - {(loading || isReloading) && ( - - + + ))} )} - + + {/* Empty state */} + {showEmpty && ( + + + + oppure{"\n"} + + { + navigation.navigate("Login"); + }} + > + Vai al login + + + )} + ); } ); @@ -207,54 +336,37 @@ const styles = StyleSheet.create({ flex: 1, paddingHorizontal: 15, }, - headerContainer: { - flexDirection: "row", - justifyContent: "flex-end", // Allinea il pulsante a destra - alignItems: "center", - marginTop: 5, - marginBottom: 5, - paddingHorizontal: 5, - minHeight: 44, // Altezza minima per evitare che vada a capo - flexWrap: "nowrap", // Impedisce il wrapping - }, - headerTitle: { - fontSize: 30, - fontWeight: "200", // Stesso peso di Home20 - color: "#000000", - fontFamily: "System", - letterSpacing: -1.5, - }, noCategoriesContainer: { textAlign: "center", marginTop: 50, paddingHorizontal: 20, }, + emptyHeader: { + marginBottom: 10, + }, noCategoriesMessage: { fontSize: 18, - color: "#666666", // Colore più morbido + color: "#666666", textAlign: "center", fontFamily: "System", fontWeight: "300", lineHeight: 26, }, reloadButton: { - backgroundColor: "#f0f0f0", // Stesso colore del send button di Home20 + backgroundColor: "#f0f0f0", paddingVertical: 12, paddingHorizontal: 12, - borderRadius: 20, // Stesso stile dei bottoni di Home20 + borderRadius: 20, alignItems: "center", justifyContent: "center", shadowColor: "#000", - shadowOffset: { - width: 0, - height: 2, - }, + shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.05, shadowRadius: 8, elevation: 2, - minWidth: 44, // Larghezza minima per evitare problemi di layout - minHeight: 44, // Altezza minima per evitare problemi di layout - flexShrink: 0, // Impedisce al pulsante di ridursi + minWidth: 44, + minHeight: 44, + flexShrink: 0, }, goToLoginButton: { width: 150, @@ -266,38 +378,47 @@ const styles = StyleSheet.create({ fontFamily: "System", fontWeight: "400", }, - loadingSpinner: { - display: "flex", - flexDirection: "column", - alignItems: "center", + // Skeleton styles + skeletonCard: { + flexDirection: "row", + padding: 16, + backgroundColor: "#FFFFFF", + borderRadius: 16, + borderWidth: 1.5, + borderColor: "#E1E5E9", + overflow: "hidden", + height: 76, justifyContent: "center", - height: Dimensions.get("window").height * 0.6, - paddingTop: 50, + ...Platform.select({ + ios: { + shadowColor: "#000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.08, + shadowRadius: 12, + }, + android: { + elevation: 3, + }, + }), + }, + skeletonRow: { + flexDirection: "row", + alignItems: "center", + flex: 1, }, - spinner: { - borderWidth: 3, - borderColor: "rgba(0, 0, 0, 0.1)", - borderLeftColor: "#000000", // Cambiato per coerenza con Home20 - borderRadius: 50, + skeletonCircle: { width: 40, height: 40, - }, - refreshButton: { - backgroundColor: "#f0f0f0", - padding: 10, borderRadius: 20, - alignItems: "center", - justifyContent: "center", - shadowColor: "#000", - shadowOffset: { - width: 0, - height: 2, - }, - shadowOpacity: 0.05, - shadowRadius: 8, - elevation: 2, - minWidth: 44, - minHeight: 44, + backgroundColor: "#E8ECF0", + }, + skeletonLine: { + height: 16, + borderRadius: 8, + backgroundColor: "#E8ECF0", + }, + skeletonShimmer: { + backgroundColor: "rgba(255,255,255,0.5)", }, }); diff --git a/src/components/Task/AddTask.tsx b/src/components/Task/AddTask.tsx index f32443e..37988ce 100644 --- a/src/components/Task/AddTask.tsx +++ b/src/components/Task/AddTask.tsx @@ -8,6 +8,9 @@ import { Modal, Alert, ScrollView, + Animated, + PanResponder, + Dimensions, } from "react-native"; import { Picker } from "@react-native-picker/picker"; import DateTimePickerModal from "react-native-modal-datetime-picker"; @@ -36,6 +39,8 @@ export type AddTaskProps = { allowCategorySelection?: boolean; // Abilita campo categoria }; +const { height: SCREEN_HEIGHT } = Dimensions.get('window'); + const AddTask: React.FC = ({ visible, onClose, @@ -55,6 +60,45 @@ const AddTask: React.FC = ({ const [priority, setPriority] = useState(1); const [title, setTitle] = useState(""); const [description, setDescription] = useState(""); + + const panY = React.useRef(new Animated.Value(0)).current; + + React.useEffect(() => { + if (visible) { + panY.setValue(0); + } + }, [visible, panY]); + + const closeWithAnimation = () => { + Animated.timing(panY, { + toValue: SCREEN_HEIGHT, + duration: 250, + useNativeDriver: true, + }).start(() => onClose()); + }; + + const panResponder = React.useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onMoveShouldSetPanResponder: (_, gestureState) => Math.abs(gestureState.dy) > 10, + onPanResponderMove: (_, gestureState) => { + if (gestureState.dy > 0) { + panY.setValue(gestureState.dy); + } + }, + onPanResponderRelease: (_, gestureState) => { + if (gestureState.dy > 100 || gestureState.vy > 1.5) { + closeWithAnimation(); + } else { + Animated.spring(panY, { + toValue: 0, + useNativeDriver: true, + bounciness: 10 + }).start(); + } + }, + }) + ).current; const [dueDate, setDueDate] = useState(""); const [selectedDateTime, setSelectedDateTime] = useState(null); const [titleError, setTitleError] = useState(""); @@ -169,8 +213,7 @@ const AddTask: React.FC = ({ id: Date.now(), title: title.trim(), description: description.trim() || "", // Assicurarsi che description non sia mai null - end_time: dueDate || null, // Se non c'è una data di scadenza, imposta null - start_time: new Date().toISOString(), + end_time: dueDate || null, priority: priorityString, status: "In sospeso", // Aggiornato per coerenza con altri componenti category_name: allowCategorySelection diff --git a/src/components/Task/CompletedTasksList.tsx b/src/components/Task/CompletedTasksList.tsx index 0d13d6a..c89fff0 100644 --- a/src/components/Task/CompletedTasksList.tsx +++ b/src/components/Task/CompletedTasksList.tsx @@ -1,5 +1,12 @@ -import React, { useState } from "react"; -import { View, Text, StyleSheet, FlatList, TouchableOpacity } from "react-native"; +import React, { useState, useEffect } from "react"; +import { + View, + Text, + StyleSheet, + FlatList, + TouchableOpacity, + Animated, +} from "react-native"; import { MaterialIcons } from "@expo/vector-icons"; export interface CompletedTaskProps { @@ -11,19 +18,62 @@ export interface CompletedTaskProps { export interface CompletedTasksListProps { tasks: CompletedTaskProps[]; onTaskPress: (taskId: number | string) => void; + isLoading?: boolean; } -const CompletedTasksList: React.FC = ({ - tasks, - onTaskPress +const SKELETON_COUNT = 3; + +const SkeletonRow: React.FC<{ index: number }> = ({ index }) => { + const shimmer = new Animated.Value(0); + + useEffect(() => { + const offset = index * 200; + const anim = Animated.loop( + Animated.sequence([ + Animated.delay(offset), + Animated.timing(shimmer, { + toValue: 1, + duration: 1200, + useNativeDriver: true, + }), + ]) + ); + anim.start(); + return () => anim.stop(); + }, [index]); + + const translateX = shimmer.interpolate({ + inputRange: [0, 1], + outputRange: [-300, 300], + }); + + return ( + + + + + + + + + + + + ); +}; + +const CompletedTasksList: React.FC = ({ + tasks, + onTaskPress, + isLoading = false, }) => { - // Stato per gestire l'espansione della lista const [isExpanded, setIsExpanded] = useState(true); - - // Stato per il conteggio dei task da mostrare const [visibleTasksCount, setVisibleTasksCount] = useState(3); - - // Formatta la data nel formato italiano + const formatDate = (dateString: string) => { const date = new Date(dateString); return date.toLocaleDateString("it-IT", { @@ -33,25 +83,24 @@ const CompletedTasksList: React.FC = ({ minute: "2-digit", }); }; - - // Gestisce il toggle dell'espansione + const toggleExpand = () => { setIsExpanded(!isExpanded); }; - - // Mostra tutti i task completati + const handleViewAll = () => { setVisibleTasksCount(tasks.length); }; - - // Filtro i task per essere sicuro che tutti abbiano un ID valido - const safeVisibleTasks = isExpanded ? - (visibleTasksCount < tasks.length ? tasks.slice(0, visibleTasksCount) : tasks) - .filter(task => task && task.id != null) // Filtro per task validi con ID definiti + + const safeVisibleTasks = isExpanded + ? (visibleTasksCount < tasks.length + ? tasks.slice(0, visibleTasksCount) + : tasks + ).filter((task) => task && task.id != null) : []; - + const renderTaskItem = ({ item }: { item: CompletedTaskProps }) => ( - onTaskPress(item.id)} > @@ -68,12 +117,34 @@ const CompletedTasksList: React.FC = ({ ); - - // Se non ci sono task completati, non mostriamo la sezione + if (!tasks || tasks.length === 0) { return null; } - + + if (isLoading) { + return ( + + + + + + + + + + + + + + + {Array.from({ length: SKELETON_COUNT }).map((_, i) => ( + + ))} + + ); + } + return ( @@ -81,34 +152,37 @@ const CompletedTasksList: React.FC = ({ Completati di recente {tasks.length} - - {isExpanded ? "Chiudi" : "Mostra"} - + - + {isExpanded && ( <> (item.id !== undefined ? item.id.toString() : `task-${Math.random()}`)} + keyExtractor={(item) => + item.id !== undefined ? item.id.toString() : `task-${Math.random()}` + } scrollEnabled={false} /> - + {tasks.length > visibleTasksCount && ( - @@ -117,10 +191,10 @@ const CompletedTasksList: React.FC = ({ )} - + {tasks.length > 0 && tasks.length <= visibleTasksCount && ( - {tasks.length} {tasks.length === 1 ? 'task completato' : 'task completati'} + {tasks.length} {tasks.length === 1 ? "task completato" : "task completati"} )} @@ -165,7 +239,8 @@ const styles = StyleSheet.create({ fontSize: 13, fontWeight: "500", color: "#ffffff", - backgroundColor: "#000000", borderRadius: 12, + backgroundColor: "#000000", + borderRadius: 12, paddingHorizontal: 8, paddingVertical: 4, marginLeft: 10, @@ -214,17 +289,6 @@ const styles = StyleSheet.create({ color: "#666666", fontFamily: "System", }, - emptyContainer: { - alignItems: "center", - justifyContent: "center", - paddingVertical: 20, - }, - emptyText: { - marginTop: 10, - color: "#666666", - fontSize: 15, - fontFamily: "System", - }, toggleButton: { flexDirection: "row", alignItems: "center", @@ -233,13 +297,82 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, borderRadius: 24, borderWidth: 1, - borderColor: "#e1e5e9", }, + borderColor: "#e1e5e9", + }, toggleButtonText: { fontSize: 15, fontWeight: "400", color: "#000000", fontFamily: "System", }, + // Skeleton styles + skeletonRow: { + flexDirection: "row", + alignItems: "center", + paddingVertical: 14, + borderBottomWidth: 1, + borderBottomColor: "#f0f0f0", + }, + skeletonCheck: { + width: 20, + height: 20, + borderRadius: 10, + backgroundColor: "#f0f0f0", + marginRight: 12, + }, + skeletonContent: { + flex: 1, + }, + skeletonTitle: { + height: 16, + width: "65%", + backgroundColor: "#f0f0f0", + borderRadius: 4, + marginBottom: 8, + overflow: "hidden", + }, + skeletonDate: { + height: 12, + width: "40%", + backgroundColor: "#f0f0f0", + borderRadius: 4, + overflow: "hidden", + }, + skeletonShimmer: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "#e8e8e8", + width: 120, + borderRadius: 4, + }, + skeletonShimmerStatic: { + ...StyleSheet.absoluteFillObject, + backgroundColor: "#eaeaea", + borderRadius: 4, + }, + skeletonTitleLabel: { + height: 20, + width: 150, + backgroundColor: "#f0f0f0", + borderRadius: 4, + overflow: "hidden", + }, + skeletonBadge: { + width: 24, + height: 20, + borderRadius: 12, + backgroundColor: "#f0f0f0", + marginLeft: 10, + overflow: "hidden", + }, + skeletonToggleButton: { + width: 80, + height: 32, + borderRadius: 16, + backgroundColor: "#f0f0f0", + borderWidth: 1, + borderColor: "#e8e8e8", + overflow: "hidden", + }, }); -export default CompletedTasksList; \ No newline at end of file +export default CompletedTasksList; diff --git a/src/components/Task/QuickAddButton.tsx b/src/components/Task/QuickAddButton.tsx index d0972cd..fb1ab9f 100644 --- a/src/components/Task/QuickAddButton.tsx +++ b/src/components/Task/QuickAddButton.tsx @@ -1,12 +1,14 @@ import React from "react"; import { TouchableOpacity, StyleSheet, Animated } from "react-native"; import { MaterialIcons } from "@expo/vector-icons"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; export interface QuickAddButtonProps { onPress: () => void; } const QuickAddButton: React.FC = ({ onPress }) => { + const insets = useSafeAreaInsets(); const scaleAnim = React.useRef(new Animated.Value(1)).current; const handlePressIn = () => { @@ -32,6 +34,7 @@ const QuickAddButton: React.FC = ({ onPress }) => { style={[ styles.container, { + bottom: 20 + insets.bottom, transform: [{ scale: scaleAnim }] } ]} diff --git a/src/components/Task/TaskCard.tsx b/src/components/Task/TaskCard.tsx index e0070b8..2e75599 100644 --- a/src/components/Task/TaskCard.tsx +++ b/src/components/Task/TaskCard.tsx @@ -1,8 +1,10 @@ import React from 'react'; -import { View, TouchableOpacity, Text, StyleSheet } from 'react-native'; +import { View, TouchableOpacity, StyleSheet } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import dayjs from 'dayjs'; import { Task } from '../../services/taskService'; +import { CardSurface, AppText, StatusChip } from '../UI/foundation'; +import { colors, spacing } from '../../theme/tokens'; export interface TaskCardProps { task: Task; @@ -10,8 +12,6 @@ export interface TaskCardProps { } const TaskCard: React.FC = ({ task, onPress }) => { - - // Funzione per sanitizzare le stringhe const sanitizeString = (value: any): string => { if (typeof value === 'string') { return value.trim(); @@ -23,7 +23,6 @@ const TaskCard: React.FC = ({ task, onPress }) => { }; const formatTaskTime = (startTime?: string, endTime?: string, nextOccurrence?: string): string => { - // For recurring tasks, show next occurrence instead of end_time const dateToShow = nextOccurrence || endTime || startTime; if (!dateToShow) { @@ -47,7 +46,6 @@ const TaskCard: React.FC = ({ task, onPress }) => { return datePrefix + timeRange; }; - // Calcola la prossima data di scadenza stimata dal pattern di ricorrenza const computeNextOccurrence = (): string | null => { const pattern = task.recurrence_pattern; if (!pattern) return null; @@ -62,9 +60,7 @@ const TaskCard: React.FC = ({ task, onPress }) => { if (pattern === 'weekly') { const days = task.recurrence_days_of_week; if (days && days.length > 0) { - // Trova il prossimo giorno della settimana corrispondente (1=Lun, 7=Dom) - // dayjs: 0=Dom, 1=Lun, ..., 6=Sab → converti - const todayDow = now.day() === 0 ? 7 : now.day(); // 1-7 Mon-Sun + const todayDow = now.day() === 0 ? 7 : now.day(); const sortedDays = [...days].sort((a, b) => a - b); const nextDay = sortedDays.find(d => d > todayDow) ?? sortedDays[0]; const daysUntil = nextDay > todayDow @@ -87,7 +83,6 @@ const TaskCard: React.FC = ({ task, onPress }) => { return null; }; - // Format recurrence description for display const getRecurrenceDescription = (): string | null => { if (!task.is_recurring || !task.recurrence_pattern) return null; @@ -116,7 +111,6 @@ const TaskCard: React.FC = ({ task, onPress }) => { return 'Ricorrente'; }; - // Format duration for display const formatDuration = (minutes?: number | null): string | null => { if (!minutes) return null; if (minutes < 60) return `${minutes} min`; @@ -128,90 +122,83 @@ const TaskCard: React.FC = ({ task, onPress }) => { return `${hours}h ${remainingMinutes}min`; }; - // Determina il colore in base alla priorità (gradiente di scurezza) const priorityColors: Record = { - 'Alta': '#000000', // Nero per alta priorità - 'Media': '#333333', // Grigio scuro per media priorità - 'Bassa': '#666666', // Grigio medio per bassa priorità - 'default': '#999999' // Grigio chiaro per default + 'Alta': '#000000', + 'Media': '#333333', + 'Bassa': '#666666', + 'default': '#999999' }; - - const cardColor = task.priority ? - priorityColors[task.priority] || priorityColors.default : - priorityColors.default; - + + const cardColor = task.priority + ? priorityColors[task.priority] || priorityColors.default + : priorityColors.default; + return ( - onPress && onPress(task)} > - + - + {sanitizeString(task.title)} - + {(task.is_recurring || task.is_generated_instance) && ( - - - + } + style={styles.recurringBadge} + /> )} {(() => { const description = sanitizeString(task.description); return description && description !== 'null' && description !== '' ? ( - + {description} - + ) : null; })()} - {/* Show recurrence pattern for recurring tasks */} {task.is_recurring && getRecurrenceDescription() && ( - + {getRecurrenceDescription()} - + )} - {/* Show completion count for recurring tasks */} {task.is_recurring && task.recurrence_current_count !== undefined && task.recurrence_current_count > 0 && ( - + Completato {task.recurrence_current_count} {task.recurrence_current_count === 1 ? 'volta' : 'volte'} - + )} - - + + {(() => { const categoryName = sanitizeString(task.category_name); return categoryName && categoryName !== 'null' && categoryName !== '' ? ( - - - {categoryName} - - + ) : null; })()} - - - - {sanitizeString(task.status)} - - + + - {/* Duration display */} {formatDuration(task.duration_minutes) && ( - - + + {formatDuration(task.duration_minutes)} - + )} - {/* Date / next occurrence */} {(() => { const isRecurring = task.is_recurring || task.is_generated_instance; if (isRecurring) { @@ -221,8 +208,8 @@ const TaskCard: React.FC = ({ task, onPress }) => { : 'Ricorrente'; return ( - - {label} + + {label} ); } @@ -232,137 +219,65 @@ const TaskCard: React.FC = ({ task, onPress }) => { : 'Nessuna scadenza'; return ( - - {label} + + {label} ); })()} - + ); }; const styles = StyleSheet.create({ - taskCard: { - backgroundColor: "#ffffff", - borderRadius: 16, - padding: 16, + card: { marginVertical: 6, marginHorizontal: 2, - shadowColor: "#000", - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.06, - shadowRadius: 8, - elevation: 2, - borderWidth: 1, - borderColor: "#f0f0f0", }, - taskCardContent: { - flexDirection: "column", + content: { + flexDirection: 'column', + gap: 0, }, titleRow: { - flexDirection: "row", - alignItems: "center", + flexDirection: 'row', + alignItems: 'center', marginBottom: 6, }, - taskTitle: { - fontSize: 16, - fontWeight: "500", - color: "#000000", - fontFamily: "System", - letterSpacing: -0.3, + title: { flex: 1, }, recurringBadge: { - backgroundColor: "#E3F2FD", - borderRadius: 12, + marginLeft: 8, paddingHorizontal: 8, paddingVertical: 4, - marginLeft: 8, - flexDirection: "row", - alignItems: "center", }, - taskDescription: { - fontSize: 14, - color: "#666666", + description: { marginBottom: 8, - lineHeight: 20, - fontFamily: "System", - fontWeight: "300", }, - taskMetadata: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", + metadata: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', marginBottom: 8, }, - taskCategory: { - backgroundColor: "#f8f8f8", - borderRadius: 8, - paddingHorizontal: 8, - paddingVertical: 4, - }, - taskCategoryText: { - fontSize: 12, - color: "#666666", - fontWeight: "400", - fontFamily: "System", - }, - taskStatus: { - backgroundColor: "#f8f8f8", - borderRadius: 8, - paddingHorizontal: 8, - paddingVertical: 4, - }, - taskStatusText: { - fontSize: 12, - fontWeight: "400", - fontFamily: "System", - }, - recurrenceDescription: { - fontSize: 12, - color: "#007AFF", + recurrenceDesc: { marginBottom: 6, - fontFamily: "System", - fontWeight: "400", }, completionCount: { - fontSize: 11, - color: "#666666", marginBottom: 6, - fontFamily: "System", - fontWeight: "300", - fontStyle: "italic", + fontStyle: 'italic', }, - durationInfo: { - flexDirection: "row", - alignItems: "center", + durationRow: { + flexDirection: 'row', + alignItems: 'center', marginTop: 4, }, - durationInfoText: { - fontSize: 12, - color: "#666666", - marginLeft: 6, - fontFamily: "System", - fontWeight: "400", - }, dateRow: { - flexDirection: "row", - alignItems: "center", + flexDirection: 'row', + alignItems: 'center', marginTop: 6, gap: 4, }, - dateRowText: { - fontSize: 12, - fontFamily: "System", - fontWeight: "400", - }, - dateRowRecurring: { - color: "#007AFF", - }, - dateRowNone: { - color: "#999999", - }, }); -export default TaskCard; \ No newline at end of file +export default TaskCard; diff --git a/src/components/TaskList/ActiveFilters.tsx b/src/components/TaskList/ActiveFilters.tsx index 01a1ddf..3fb29b3 100644 --- a/src/components/TaskList/ActiveFilters.tsx +++ b/src/components/TaskList/ActiveFilters.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { View, Text, TouchableOpacity } from 'react-native'; +import { View, Text, TouchableOpacity, ScrollView } from 'react-native'; import { styles } from './styles'; export interface ActiveFiltersProps { @@ -23,17 +23,19 @@ export const ActiveFilters = ({ return ( - Filtri attivi: - + {importanceFilter !== 'Tutte' && ( - Importanza: {importanceFilter} + {importanceFilter} - × )} @@ -43,12 +45,11 @@ export const ActiveFilters = ({ onPress={onClearDeadlineFilter} > - Scadenza: {deadlineFilter} + {deadlineFilter} - × )} - + ); }; diff --git a/src/components/TaskList/AddTaskButton.tsx b/src/components/TaskList/AddTaskButton.tsx index 25aa6a2..073a70d 100644 --- a/src/components/TaskList/AddTaskButton.tsx +++ b/src/components/TaskList/AddTaskButton.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { TouchableOpacity } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { styles } from './styles'; export interface AddTaskButtonProps { @@ -8,8 +9,9 @@ export interface AddTaskButtonProps { } export const AddTaskButton = ({ onPress }: AddTaskButtonProps) => { + const insets = useSafeAreaInsets(); return ( - + ); diff --git a/src/components/TaskList/FilterModal.tsx b/src/components/TaskList/FilterModal.tsx index 7cd616a..6f70c04 100644 --- a/src/components/TaskList/FilterModal.tsx +++ b/src/components/TaskList/FilterModal.tsx @@ -1,5 +1,5 @@ -import React from 'react'; -import { View, Text, Modal, TouchableOpacity, ScrollView } from 'react-native'; +import React, { useRef, useEffect } from 'react'; +import { View, Text, Modal, TouchableOpacity, ScrollView, Animated, PanResponder, Dimensions } from 'react-native'; import { styles } from './styles'; import { FilterChip } from './FilterChip'; @@ -14,6 +14,8 @@ export interface FilterModalProps { setOrdineScadenza: (value: string) => void; } +const { height: SCREEN_HEIGHT } = Dimensions.get('window'); + export const FilterModal = ({ visible, onClose, @@ -24,28 +26,92 @@ export const FilterModal = ({ ordineScadenza, setOrdineScadenza }: FilterModalProps) => { + + const panY = useRef(new Animated.Value(0)).current; + + // Resetta l'animazione quando il modal si apre + useEffect(() => { + if (visible) { + panY.setValue(0); + } + }, [visible, panY]); + + const closeWithAnimation = () => { + Animated.timing(panY, { + toValue: SCREEN_HEIGHT, + duration: 250, + useNativeDriver: true, + }).start(() => onClose()); + }; + + const panResponder = useRef( + PanResponder.create({ + onStartShouldSetPanResponder: () => true, + onMoveShouldSetPanResponder: (_, gestureState) => { + // Inizia il drag solo se ci si muove in verticale di almeno 10 pixel + return Math.abs(gestureState.dy) > 10; + }, + onPanResponderMove: (_, gestureState) => { + // Permetti solo il drag verso il basso + if (gestureState.dy > 0) { + panY.setValue(gestureState.dy); + } + }, + onPanResponderRelease: (_, gestureState) => { + // Se l'utente ha trascinato abbastanza giù o con velocità sufficiente, chiudi + if (gestureState.dy > 100 || gestureState.vy > 1.5) { + closeWithAnimation(); + } else { + // Altrimenti rimbalza alla posizione originale + Animated.spring(panY, { + toValue: 0, + useNativeDriver: true, + bounciness: 10 + }).start(); + } + }, + }) + ).current; + return ( - - - Filtra task - - × - + + + + {/* L'header e la drag handle sono responsabili di catturare il gesto di swipe */} + + + + + + Filtra task + + × + + - + {/* Filtro per importanza */} - + Importanza Scadenza - + setFiltroScadenza("Dopodomani")} /> - - setFiltroScadenza("Senza scadenza")} color="#999999" /> - + {/* Ordine di visualizzazione */} - + Ordina per scadenza Applica Filtri - + ); diff --git a/src/components/TaskList/TaskListContainer.tsx b/src/components/TaskList/TaskListContainer.tsx index 55fee33..b8440a1 100644 --- a/src/components/TaskList/TaskListContainer.tsx +++ b/src/components/TaskList/TaskListContainer.tsx @@ -1,9 +1,11 @@ -import React, { useState, useEffect, useMemo, useRef, useCallback } from 'react'; -import { View, ScrollView, ActivityIndicator, Alert, Animated, Easing } from 'react-native'; +import React, { useState, useEffect, useMemo, useRef, useCallback, useLayoutEffect } from 'react'; +import { View, ScrollView, Alert, Animated, Easing } from 'react-native'; import { useTranslation } from 'react-i18next'; +import { useNavigation } from '@react-navigation/native'; +import { Ionicons } from '@expo/vector-icons'; +import { TouchableOpacity } from 'react-native'; import { styles } from './styles'; import { Task as TaskType, globalTasksRef } from './types'; -import { TaskListHeader } from './TaskListHeader'; import eventEmitter, { EVENTS } from '../../utils/eventEmitter'; import { ActiveFilters } from './ActiveFilters'; import { FilterModal } from './FilterModal'; @@ -12,6 +14,7 @@ import { AddTaskButton } from './AddTaskButton'; import { filterTasksByDay } from './TaskUtils'; import AddTask from '../Task/AddTask'; import { recurringTaskService, RecurringTask, CreateRecurringTaskPayload } from '../../services/recurringTaskService'; +import { LoadingState, EmptyState, SectionHeader } from '../UI/foundation'; export interface TaskListContainerProps { categoryName: string; @@ -48,11 +51,27 @@ export const TaskListContainer = ({ // Stati per le sezioni collassabili const [todoSectionExpanded, setTodoSectionExpanded] = useState(true); - const [completedSectionExpanded, setCompletedSectionExpanded] = useState(true); + const [completedSectionExpanded, setCompletedSectionExpanded] = useState(false); // Collapsed by default to hide them // Animated values per le animazioni di altezza const todoSectionHeight = useRef(new Animated.Value(1)).current; - const completedSectionHeight = useRef(new Animated.Value(1)).current; + const completedSectionHeight = useRef(new Animated.Value(0)).current; + + const navigation = useNavigation(); + + useLayoutEffect(() => { + navigation.setOptions({ + title: categoryName, + headerRight: () => ( + setModalVisible(true)} + > + + + ), + }); + }, [navigation, categoryName]); // Initialize the global task adder function globalTasksRef.addTask = (newTask: TaskType, category: string) => { @@ -519,13 +538,8 @@ export const TaskListContainer = ({ return ( - setModalVisible(true)} - /> - {isLoading ? ( - + ) : ( {/* Modal dei filtri */} @@ -540,47 +554,51 @@ export const TaskListContainer = ({ setOrdineScadenza={setOrdineScadenza} /> - {/* Visualizzazione filtri attivi */} - setFiltroImportanza("Tutte")} - onClearDeadlineFilter={() => setFiltroScadenza("Tutte")} - /> - - {/* Sezione task non completati */} - toggleSection(todoSectionExpanded, setTodoSectionExpanded, todoSectionHeight)} - renderTask={(item, index) => { - // Ensure every task has a valid ID - const taskId = item.id || item.task_id || `fallback_${Date.now()}_${index}_${Math.random().toString(36).substr(2, 9)}`; - return ( - - ); - }} - emptyMessage={t('taskList.sections.emptyTodo')} - /> + {/* Sezione task non completati (senza contenitore collapsabile) */} + + + + {/* Visualizzazione filtri attivi (spostata sotto il titolo "Da fare") */} + setFiltroImportanza("Tutte")} + onClearDeadlineFilter={() => setFiltroScadenza("Tutte")} + /> - {/* Sezione task completati */} + {listaFiltrata.length > 0 ? ( + <> + {listaFiltrata.map((item, index) => { + const taskId = item.id || item.task_id || `fallback_${Date.now()}_${index}_${Math.random().toString(36).substr(2, 9)}`; + return ( + + ); + })} + + ) : ( + } + title={t('taskList.sections.emptyTodo') || 'Nessun task da fare'} + /> + )} + + + {/* Sezione task completati (collapsabile in basso) */} {completedTasks.length > 0 && ( ; +} + +export default function AppText({ + variant = "body", + color = colors.textPrimary, + weight, + style, + children, + ...props +}: AppTextProps) { + const typeStyle = typography[variant]; + + return ( + + {children} + + ); +} + +const styles = StyleSheet.create({ + base: { + fontFamily: "System", + }, +}); diff --git a/src/components/UI/foundation/CONVENTIONS.md b/src/components/UI/foundation/CONVENTIONS.md new file mode 100644 index 0000000..c57bad6 --- /dev/null +++ b/src/components/UI/foundation/CONVENTIONS.md @@ -0,0 +1,22 @@ +## UI Foundation Conventions + +Questa cartella contiene primitive UI riutilizzabili e composabili. + +## Folder Targets + +- `src/theme`: token, scale e helper di stile condivisi. +- `src/components/UI/foundation`: componenti primitivi presentazionali. + +## Naming Convention + +- File componenti: `PascalCase.tsx` (es. `AppText.tsx`, `ScreenHeader.tsx`). +- File tema/token: `camelCase.ts` (es. `tokens.ts`, `primitives.ts`). +- Export pubblici: centralizzati in `index.ts`. +- Props types: `Props`. +- Varianti: prop `variant` con union type string literal. + +## Design Rules + +- Primitive senza logica business (solo rendering/stile/accessibility base). +- Nessun accesso diretto a service, navigation o stato globale. +- Stili hardcoded limitati: preferire token da `src/theme/tokens.ts`. diff --git a/src/components/UI/foundation/CardSurface.tsx b/src/components/UI/foundation/CardSurface.tsx new file mode 100644 index 0000000..c608f58 --- /dev/null +++ b/src/components/UI/foundation/CardSurface.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { Pressable, PressableProps, StyleProp, StyleSheet, View, ViewStyle } from "react-native"; +import { colors, elevation, radius, spacing } from "../../../theme/tokens"; + +type CardVariant = "default" | "outlined" | "interactive"; + +export interface CardSurfaceProps extends Omit { + variant?: CardVariant; + accentColor?: string; + accentWidth?: number; + style?: StyleProp; + children: React.ReactNode; +} + +export default function CardSurface({ + variant = "default", + accentColor, + accentWidth = 0, + style, + children, + ...props +}: CardSurfaceProps) { + const cardStyle = [ + styles.base, + variant === "outlined" && styles.outlined, + variant === "interactive" && styles.interactive, + accentColor ? { borderLeftColor: accentColor, borderLeftWidth: accentWidth || 4 } : null, + style, + ]; + + if (variant === "interactive" || props.onPress) { + return ( + + {children} + + ); + } + + return {children}; +} + +const styles = StyleSheet.create({ + base: { + backgroundColor: colors.surface, + borderRadius: radius.lg, + padding: spacing.lg, + borderWidth: 1, + borderColor: colors.borderSoft, + ...elevation.sm, + }, + outlined: { + borderColor: colors.border, + ...elevation.none, + }, + interactive: { + ...elevation.md, + }, +}); diff --git a/src/components/UI/foundation/ContentContainer.tsx b/src/components/UI/foundation/ContentContainer.tsx new file mode 100644 index 0000000..a1a47dc --- /dev/null +++ b/src/components/UI/foundation/ContentContainer.tsx @@ -0,0 +1,33 @@ +import React from "react"; +import { StyleProp, StyleSheet, View, ViewProps, ViewStyle } from "react-native"; +import { spacing } from "../../../theme/tokens"; + +export interface ContentContainerProps extends ViewProps { + padded?: boolean; + style?: StyleProp; +} + +export default function ContentContainer({ + children, + padded = true, + style, + ...props +}: ContentContainerProps) { + return ( + + {children} + + ); +} + +const styles = StyleSheet.create({ + base: { + flex: 1, + }, + padded: { + paddingHorizontal: spacing.lg, + }, +}); diff --git a/src/components/UI/foundation/EmptyState.tsx b/src/components/UI/foundation/EmptyState.tsx new file mode 100644 index 0000000..ae4af64 --- /dev/null +++ b/src/components/UI/foundation/EmptyState.tsx @@ -0,0 +1,67 @@ +import React from "react"; +import { StyleProp, StyleSheet, TouchableOpacity, View, ViewStyle } from "react-native"; +import AppText from "./AppText"; +import { colors, spacing } from "../../../theme/tokens"; + +export interface EmptyStateProps { + icon?: React.ReactNode; + title: string; + description?: string; + ctaLabel?: string; + onPressCta?: () => void; + style?: StyleProp; +} + +export default function EmptyState({ + icon, + title, + description, + ctaLabel, + onPressCta, + style, +}: EmptyStateProps) { + return ( + + {icon} + + {title} + + {description ? ( + + {description} + + ) : null} + {ctaLabel && onPressCta ? ( + + + {ctaLabel} + + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + container: { + alignItems: "center", + justifyContent: "center", + paddingVertical: spacing.xxxl, + paddingHorizontal: spacing.xl, + }, + title: { + marginTop: spacing.md, + textAlign: "center", + }, + description: { + marginTop: spacing.sm, + textAlign: "center", + }, + cta: { + marginTop: spacing.lg, + backgroundColor: colors.textPrimary, + borderRadius: 999, + paddingHorizontal: spacing.lg, + paddingVertical: spacing.sm, + }, +}); diff --git a/src/components/UI/foundation/IconActionButton.tsx b/src/components/UI/foundation/IconActionButton.tsx new file mode 100644 index 0000000..c131c80 --- /dev/null +++ b/src/components/UI/foundation/IconActionButton.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { Pressable, PressableProps, StyleProp, StyleSheet, ViewStyle } from "react-native"; +import { radius, spacing } from "../../../theme/tokens"; + +export interface IconActionButtonProps extends Omit { + style?: StyleProp; + children: React.ReactNode; +} + +export default function IconActionButton({ + children, + style, + ...props +}: IconActionButtonProps) { + return ( + [ + styles.button, + pressed && styles.pressed, + style, + ]} + > + {children} + + ); +} + +const styles = StyleSheet.create({ + button: { + padding: spacing.sm, + borderRadius: radius.pill, + }, + pressed: { + opacity: 0.7, + }, +}); diff --git a/src/components/UI/foundation/InputShell.tsx b/src/components/UI/foundation/InputShell.tsx new file mode 100644 index 0000000..12010b9 --- /dev/null +++ b/src/components/UI/foundation/InputShell.tsx @@ -0,0 +1,62 @@ +import React from "react"; +import { + StyleProp, + StyleSheet, + TextInput, + TextInputProps, + View, + ViewStyle, +} from "react-native"; +import { colors, elevation, radius, spacing } from "../../../theme/tokens"; + +export interface InputShellProps extends TextInputProps { + leftSlot?: React.ReactNode; + rightSlot?: React.ReactNode; + containerStyle?: StyleProp; +} + +export default function InputShell({ + leftSlot, + rightSlot, + containerStyle, + style, + ...props +}: InputShellProps) { + return ( + + {leftSlot ? {leftSlot} : null} + + {rightSlot ? {rightSlot} : null} + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: "row", + alignItems: "center", + width: "100%", + minHeight: 50, + borderRadius: radius.pill, + borderWidth: 1.5, + borderColor: colors.border, + backgroundColor: colors.surface, + paddingHorizontal: spacing.md, + paddingVertical: spacing.sm, + ...elevation.sm, + }, + slot: { + marginHorizontal: spacing.xs, + }, + input: { + flex: 1, + color: colors.textPrimary, + fontFamily: "System", + fontSize: 15, + paddingVertical: spacing.xs, + }, +}); diff --git a/src/components/UI/foundation/LoadingState.tsx b/src/components/UI/foundation/LoadingState.tsx new file mode 100644 index 0000000..27d173b --- /dev/null +++ b/src/components/UI/foundation/LoadingState.tsx @@ -0,0 +1,90 @@ +import React, { useEffect, useRef } from "react"; +import { ActivityIndicator, Animated, StyleProp, StyleSheet, View, ViewStyle } from "react-native"; +import AppText from "./AppText"; +import { colors, spacing } from "../../../theme/tokens"; + +type LoadingVariant = "spinner" | "dots"; + +export interface LoadingStateProps { + variant?: LoadingVariant; + label?: string; + style?: StyleProp; +} + +export default function LoadingState({ + variant = "spinner", + label, + style, +}: LoadingStateProps) { + const dots = [ + useRef(new Animated.Value(0.2)).current, + useRef(new Animated.Value(0.2)).current, + useRef(new Animated.Value(0.2)).current, + ]; + + useEffect(() => { + if (variant !== "dots") return; + + const loops = dots.map((dot, index) => + Animated.loop( + Animated.sequence([ + Animated.delay(index * 160), + Animated.timing(dot, { + toValue: 1, + duration: 360, + useNativeDriver: true, + }), + Animated.timing(dot, { + toValue: 0.2, + duration: 360, + useNativeDriver: true, + }), + ]) + ) + ); + + loops.forEach((loop) => loop.start()); + return () => loops.forEach((loop) => loop.stop()); + }, [dots, variant]); + + return ( + + {variant === "spinner" ? ( + + ) : ( + + {dots.map((dot, index) => ( + + ))} + + )} + {label ? ( + + {label} + + ) : null} + + ); +} + +const styles = StyleSheet.create({ + container: { + alignItems: "center", + justifyContent: "center", + paddingVertical: spacing.xxl, + }, + dots: { + flexDirection: "row", + alignItems: "center", + }, + dot: { + width: 8, + height: 8, + borderRadius: 4, + backgroundColor: colors.textPrimary, + marginHorizontal: spacing.xs, + }, + label: { + marginTop: spacing.md, + }, +}); diff --git a/src/components/UI/foundation/ModalShell.tsx b/src/components/UI/foundation/ModalShell.tsx new file mode 100644 index 0000000..210625e --- /dev/null +++ b/src/components/UI/foundation/ModalShell.tsx @@ -0,0 +1,69 @@ +import React from "react"; +import { Modal, ModalProps, StyleSheet, TouchableWithoutFeedback, View } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { colors, radius, spacing } from "../../../theme/tokens"; + +export interface ModalShellProps extends Omit { + visible: boolean; + onClose: () => void; + header?: React.ReactNode; + footer?: React.ReactNode; + children: React.ReactNode; +} + +export default function ModalShell({ + visible, + onClose, + header, + footer, + children, + ...props +}: ModalShellProps) { + return ( + + + + + + {header ? {header} : null} + {children} + {footer ? {footer} : null} + + + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: "rgba(0, 0, 0, 0.35)", + justifyContent: "center", + padding: spacing.lg, + }, + card: { + backgroundColor: colors.surface, + borderRadius: radius.lg, + overflow: "hidden", + }, + header: { + paddingHorizontal: spacing.lg, + paddingTop: spacing.lg, + }, + body: { + paddingHorizontal: spacing.lg, + paddingVertical: spacing.md, + }, + footer: { + paddingHorizontal: spacing.lg, + paddingBottom: spacing.lg, + }, +}); diff --git a/src/components/UI/foundation/README.md b/src/components/UI/foundation/README.md new file mode 100644 index 0000000..a63f021 --- /dev/null +++ b/src/components/UI/foundation/README.md @@ -0,0 +1,131 @@ +# UI Foundation Primitives + +Composable, token-driven React Native components for consistent UI across MyTaskly screens. + +## Import + +```typescript +import { AppText, CardSurface, LoadingState } from '../UI/foundation'; +``` + +## Tokens (`src/theme/tokens.ts`) + +All primitives consume shared tokens. Use them directly in screen styles too: + +| Token | Values | +|-------|--------| +| `spacing` | `xxs`(2) `xs`(4) `sm`(8) `md`(12) `lg`(16) `xl`(20) `xxl`(24) `xxxl`(32) | +| `radius` | `sm`(8) `md`(12) `lg`(16) `xl`(20) `pill`(999) | +| `elevation` | `none` `sm` `md` `lg` | +| `colors` | `background` `surface` `surfaceMuted` `border` `borderSoft` `textPrimary` `textSecondary` `textTertiary` `accent` `success` `warning` `danger` | +| `typography` | `display` `title` `subtitle` `body` `caption` `label` | + +## Components + +### `AppText` +Typography primitive. Wraps `Text` with token-based variants. + +```tsx +Heading +Description +12px label +``` + +### `ScreenContainer` / `ContentContainer` +Screen shell with safe area + background, and padded content wrapper. + +```tsx + + + {children} + + +``` + +### `ScreenHeader` +Title + optional subtitle + right action slot. + +```tsx +} /> +``` + +### `CardSurface` +Versatile card with `default`, `outlined`, `interactive` variants. Supports accent left border. + +```tsx + + {children} + +``` + +### `SectionHeader` +Title row with optional subtitle and action slot. + +```tsx +} /> +``` + +### `StatusChip` +Inline badge with semantic tones. + +```tsx + +} /> + +``` + +Tones: `neutral`, `accent`, `success`, `warning`, `danger` + +### `LoadingState` +Shared loading indicator with `spinner` and `dots` variants. + +```tsx + + +``` + +### `EmptyState` +Centered empty state with icon, title, description, optional CTA. + +```tsx +} + title="No tasks" + description="Add your first task to get started" + ctaLabel="Add Task" + onPressCta={onAdd} +/> +``` + +### `ModalShell` +Modal with header/body/footer slots and safe area handling. + +```tsx +} footer={}> + + +``` + +### `InputShell` +Row with leading/trailing action slots and text input. + +### `IconActionButton` +Icon-only circular button with optional loading state. + +## Do / Don't + +### Do +- Compose primitives instead of creating new monolithic components +- Use `AppText` variants for all text (avoid raw `` in new code) +- Reference `colors`, `spacing`, `radius`, `elevation` tokens in styles +- Use `StatusChip` for badges, tags, and inline status indicators +- Use `CardSurface` for any boxed content (tasks, categories, info cards) +- Use `LoadingState` / `EmptyState` for screen-level feedback +- Keep primitives presentational — pass data/actions via props + +### Don't +- Create new "SmartScreenScaffold" or macro-components that combine multiple primitives +- Put business logic, service calls, or navigation inside foundation primitives +- Duplicate chip/loading/empty patterns locally — always import from foundation +- Use hardcoded color strings in new code — use token constants +- Pass `style` overrides that contradict token values without good reason diff --git a/src/components/UI/foundation/ScreenContainer.tsx b/src/components/UI/foundation/ScreenContainer.tsx new file mode 100644 index 0000000..bd43191 --- /dev/null +++ b/src/components/UI/foundation/ScreenContainer.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import { StyleProp, StyleSheet, ViewStyle } from "react-native"; +import { SafeAreaView, SafeAreaViewProps } from "react-native-safe-area-context"; +import { colors } from "../../../theme/tokens"; + +export interface ScreenContainerProps extends SafeAreaViewProps { + style?: StyleProp; +} + +export default function ScreenContainer({ + children, + style, + ...props +}: ScreenContainerProps) { + return ( + + {children} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, +}); diff --git a/src/components/UI/foundation/ScreenHeader.tsx b/src/components/UI/foundation/ScreenHeader.tsx new file mode 100644 index 0000000..267635e --- /dev/null +++ b/src/components/UI/foundation/ScreenHeader.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { StyleProp, StyleSheet, View, ViewStyle } from "react-native"; +import AppText from "./AppText"; +import { spacing } from "../../../theme/tokens"; + +type HeaderAlignment = "top" | "center"; + +export interface ScreenHeaderProps { + title: string; + rightActions?: React.ReactNode; + alignment?: HeaderAlignment; + style?: StyleProp; +} + +export default function ScreenHeader({ + title, + rightActions, + alignment = "top", + style, +}: ScreenHeaderProps) { + return ( + + + {title} + + {rightActions ? {rightActions} : null} + + ); +} + +const styles = StyleSheet.create({ + base: { + flexDirection: "row", + justifyContent: "space-between", + paddingHorizontal: spacing.lg, + paddingBottom: spacing.sm, + }, + top: { + alignItems: "flex-start", + }, + center: { + alignItems: "center", + }, + title: { + flex: 1, + }, + actions: { + marginLeft: spacing.md, + flexDirection: "row", + alignItems: "center", + }, +}); diff --git a/src/components/UI/foundation/SectionHeader.tsx b/src/components/UI/foundation/SectionHeader.tsx new file mode 100644 index 0000000..446a742 --- /dev/null +++ b/src/components/UI/foundation/SectionHeader.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { StyleProp, StyleSheet, View, ViewStyle } from "react-native"; +import AppText from "./AppText"; +import { spacing } from "../../../theme/tokens"; + +export interface SectionHeaderProps { + title: string; + subtitle?: string; + action?: React.ReactNode; + style?: StyleProp; +} + +export default function SectionHeader({ + title, + subtitle, + action, + style, +}: SectionHeaderProps) { + return ( + + + {title} + {subtitle ? ( + + {subtitle} + + ) : null} + + {action ? {action} : null} + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + marginBottom: spacing.sm, + }, + textBlock: { + flex: 1, + }, + subtitle: { + marginTop: spacing.xs, + }, + action: { + marginLeft: spacing.md, + }, +}); diff --git a/src/components/UI/foundation/StatusChip.tsx b/src/components/UI/foundation/StatusChip.tsx new file mode 100644 index 0000000..4b37d9d --- /dev/null +++ b/src/components/UI/foundation/StatusChip.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { StyleProp, StyleSheet, TextStyle, View, ViewStyle } from "react-native"; +import AppText from "./AppText"; +import { colors, radius, spacing } from "../../../theme/tokens"; + +type ChipTone = "neutral" | "accent" | "success" | "warning" | "danger"; + +export interface StatusChipProps { + label: string; + tone?: ChipTone; + style?: StyleProp; + textStyle?: StyleProp; + leftIcon?: React.ReactNode; +} + +const toneMap = { + neutral: { bg: "#f0f0f0", fg: colors.textSecondary }, + accent: { bg: "#eaf2ff", fg: colors.accent }, + success: { bg: "#eafaf2", fg: colors.success }, + warning: { bg: "#fff6e8", fg: colors.warning }, + danger: { bg: "#fdecec", fg: colors.danger }, +} as const; + +export default function StatusChip({ + label, + tone = "neutral", + style, + textStyle, + leftIcon, +}: StatusChipProps) { + const colorSet = toneMap[tone]; + + return ( + + {leftIcon ? {leftIcon} : null} + + {label} + + + ); +} + +const styles = StyleSheet.create({ + base: { + flexDirection: "row", + alignItems: "center", + alignSelf: "flex-start", + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + borderRadius: radius.md, + }, + icon: { + marginRight: spacing.xs, + }, +}); diff --git a/src/components/UI/foundation/index.ts b/src/components/UI/foundation/index.ts new file mode 100644 index 0000000..d8a5738 --- /dev/null +++ b/src/components/UI/foundation/index.ts @@ -0,0 +1,12 @@ +export { default as AppText } from "./AppText"; +export { default as ScreenContainer } from "./ScreenContainer"; +export { default as ContentContainer } from "./ContentContainer"; +export { default as ScreenHeader } from "./ScreenHeader"; +export { default as CardSurface } from "./CardSurface"; +export { default as SectionHeader } from "./SectionHeader"; +export { default as StatusChip } from "./StatusChip"; +export { default as LoadingState } from "./LoadingState"; +export { default as EmptyState } from "./EmptyState"; +export { default as ModalShell } from "./ModalShell"; +export { default as InputShell } from "./InputShell"; +export { default as IconActionButton } from "./IconActionButton"; diff --git a/src/constants/planLimits.ts b/src/constants/planLimits.ts new file mode 100644 index 0000000..b692896 --- /dev/null +++ b/src/constants/planLimits.ts @@ -0,0 +1,80 @@ +export type BillingPeriod = 'monthly' | 'annual'; + +export interface PlanProductIds { + monthly: string; + annual: string; +} + +export interface PlanLimits { + chatTextDaily: number; + chatTextMonthly: number; + chatVoiceDaily: number; + chatVoiceMonthly: number; + aiModel: 'base' | 'advanced'; + maxCategories: number; +} + +export interface Plan { + id: 'free' | 'pro' | 'premium'; + name: string; + limits: PlanLimits; + productIds?: PlanProductIds; + /** Get the product ID for a specific billing period */ + getProductId: (period: BillingPeriod) => string | undefined; +} + +export const PLAN_PRODUCT_IDS: Record<'free' | 'pro' | 'premium', PlanProductIds | undefined> = { + free: undefined, + pro: { + monthly: 'mytaskly_pro_monthly:pro-monthly', + annual: 'mytaskly_pro_monthly:pro-annual', + }, + premium: { + monthly: 'premium:monthly', + annual: 'premium:annual-new', + }, +}; + +export const PLANS: Record<'free' | 'pro' | 'premium', Plan> = { + free: { + id: 'free', + name: 'Free', + limits: { + chatTextDaily: 20, + chatTextMonthly: 130, + chatVoiceDaily: Infinity, + chatVoiceMonthly: 20, + aiModel: 'base', + maxCategories: 5, + }, + getProductId: () => undefined, + }, + pro: { + id: 'pro', + name: 'Pro', + productIds: PLAN_PRODUCT_IDS.pro, + limits: { + chatTextDaily: 50, + chatTextMonthly: 250, + chatVoiceDaily: Infinity, + chatVoiceMonthly: 50, + aiModel: 'advanced', + maxCategories: Infinity, + }, + getProductId: (period: BillingPeriod) => PLAN_PRODUCT_IDS.pro?.[period], + }, + premium: { + id: 'premium', + name: 'Premium', + productIds: PLAN_PRODUCT_IDS.premium, + limits: { + chatTextDaily: Infinity, + chatTextMonthly: 400, + chatVoiceDaily: Infinity, + chatVoiceMonthly: 150, + aiModel: 'advanced', + maxCategories: Infinity, + }, + getProductId: (period: BillingPeriod) => PLAN_PRODUCT_IDS.premium?.[period], + }, +}; diff --git a/src/hooks/useVoiceChat.ts b/src/hooks/useVoiceChat.ts index 60be61e..4e86cf2 100644 --- a/src/hooks/useVoiceChat.ts +++ b/src/hooks/useVoiceChat.ts @@ -59,6 +59,7 @@ export function useVoiceChat() { const [chunksReceived, setChunksReceived] = useState(0); const [isMuted, setIsMuted] = useState(false); const [isVoiceQuotaExceeded, setIsVoiceQuotaExceeded] = useState(false); + const [isVoiceMonthlyLimitReached, setIsVoiceMonthlyLimitReached] = useState(false); // Trascrizioni e tool const [transcripts, setTranscripts] = useState([]); @@ -120,7 +121,8 @@ export function useVoiceChat() { onToolOutput: (...args) => websocketCallbacksRef.current.onToolOutput?.(...args), onDone: (...args) => websocketCallbacksRef.current.onDone?.(...args), onError: (...args) => websocketCallbacksRef.current.onError?.(...args), - onVoiceQuotaExceeded: () => websocketCallbacksRef.current.onVoiceQuotaExceeded?.(), + onVoiceQuotaExceeded: () => websocketCallbacksRef.current.onVoiceQuotaExceeded?.(), + onVoiceMonthlyLimitReached: () => websocketCallbacksRef.current.onVoiceMonthlyLimitReached?.(), }).current; // Aggiorna il ref ad ogni render con le callback che chiudono su stato/ref correnti @@ -300,6 +302,12 @@ export function useVoiceChat() { setIsVoiceQuotaExceeded(true); setState('error'); }, + + onVoiceMonthlyLimitReached: () => { + if (!isMountedRef.current) return; + setIsVoiceMonthlyLimitReached(true); + setState('error'); + }, }; /** @@ -661,6 +669,7 @@ export function useVoiceChat() { chunksReceived, isMuted, isVoiceQuotaExceeded, + isVoiceMonthlyLimitReached, // Trascrizioni e tool transcripts, diff --git a/src/locales/en.json b/src/locales/en.json index 03b98fa..1386172 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -9,31 +9,21 @@ "statistics": "Statistics" }, "screens": { - "login": "Login", - "register": "Register", - "emailVerification": "Email Verification", - "verificationSuccess": "Verification Complete", - "home": "Home", - "taskList": "Task List", - "categories": "Categories", - "notes": "Notes", - "calendar": "Calendar", - "statistics": "Statistics", "profile": "Profile", "settings": "Settings", - "accountSettings": "Account Settings", - "changePassword": "Change Password", - "help": "Help", - "about": "About", + "accountSettings": "Account settings", + "changePassword": "Change password", + "help": "Help & support", + "about": "About app", "language": "Language", - "voiceSettings": "Voice Settings", + "voiceSettings": "Voice settings", "googleCalendar": "Google Calendar", + "notificationSettings": "Notifications", "notificationDebug": "Notification Debug", - "bugReport": "Bug Report", - "notFound": "Page not found", - "notificationSettings": "Notification Settings", - "memorySettings": "Memory Settings", - "aiSettings": "AI Settings" + "bugReport": "Report a bug", + "memorySettings": "AI Memory", + "aiSettings": "Artificial Intelligence Settings", + "subscriptionPlans": "Pricing" } }, "settings": { @@ -918,12 +908,17 @@ "upgrade": "Upgrade plan", "loading": "Loading plan info...", "error": "Unable to load plan information. Tap to retry.", - "quotaExceeded": "You have reached the monthly message limit for your {{plan}} plan. Counters reset on {{date}}.", - "voiceQuotaExceeded": "You have used all monthly voice requests for the {{plan}} plan. You can still use the text chat.", + "dailyMessages": "Daily messages", + "voiceEnabled": "Enabled", + "voiceDisabled": "Not available", + "voiceMonthlyLimitTitle": "Monthly voice limit reached", + "voiceMonthlyLimitExceeded": "You have used all your monthly voice requests. Resets on the 1st of next month.", + "quotaExceeded": "You have reached the daily message limit for your {{plan}} plan. Resets at midnight UTC.", + "voiceQuotaExceeded": "Voice chat is not available on your current plan. Upgrade to unlock it.", "goToPlan": "View plan", - "remaining": "{{n}} messages left this month", - "remainingWarning": "Only {{n}} messages left this month", - "quotaZero": "Monthly limit reached" + "remaining": "{{n}} messages left today", + "remainingWarning": "Only {{n}} messages left today", + "quotaZero": "Daily limit reached" }, "memorySettings": { "loading": "Loading memories...", @@ -962,5 +957,58 @@ "deleteFailed": "Unable to delete the memory. Check the ID and try again.", "deleteAllFailed": "Unable to delete all memories. Please try again." } + }, + "subscriptionPlans": { + "description": "Choose the plan that fits your needs. Upgrade anytime from Settings.", + "currentPlan": "Current plan", + "upgradeTo": "Upgrade to {{plan}}", + "unavailable": "Unavailable", + "features": { + "chatTextDaily": "Daily text messages", + "chatTextMonthly": "Monthly text messages", + "chatVoiceDaily": "Daily voice messages", + "chatVoiceMonthly": "Monthly voice messages", + "aiModel": "AI model", + "maxCategories": "Categories" + }, + "purchaseSuccess": "Purchase successful!", + "purchaseError": "Purchase failed. Please try again.", + "cancelConfirm": "Cancel subscription?", + "cancelConfirmMessage": "Your subscription will remain active until the end of your current billing period.", + "cancelSuccess": "Subscription cancelled successfully", + "cancelError": "Unable to cancel subscription. Please try again.", + "cancelSubscription": "Cancel subscription", + "gracePeriod": "Your access remains active until {{date}}", + "restoreSuccess": "Purchases restored successfully!", + "restoreError": "Unable to restore purchases", + "noPurchasesFound": "No purchases found to restore", + "restorePurchases": "Restore purchases", + "monthly": "Monthly", + "annual": "Annual", + "savePercent": "Save {{percent}}%", + "freeTrial": "Free trial", + "freeTrialDuration": "{{days}}-day free trial", + "perMonth": "/month", + "perYear": "/year", + "thenPrice": "then {{price}}", + "perMonthShort": "/mo", + "perYearShort": "/yr", + "selectPlan": "Select plan", + "active": "Active", + "topFeatures": "Top features", + "featTextTitle": "Text Chats", + "featTextDesc": "Include {{limits}}", + "featVoiceTitle": "Voice Interactions", + "featVoiceDesc": "Include {{limits}}", + "featAiTitle": "AI Intelligence", + "featAiDesc": "Powered by {{model}} model for accurate responses", + "featCategoriesTitle": "Organization", + "featCategoriesDesc": "Organize tasks in up to {{num}} categories", + "freeDescription": "Just the basics", + "premiumDesc": "Unlock all premium features", + "offlineDescription": "Details currently unavailable.", + "offlineWarning": "Offline. Prices unavailable.", + "offlineError": "Service offline or plan unavailable.", + "getPlan": "Get {{plan}}" } } diff --git a/src/locales/it.json b/src/locales/it.json index 8d83aea..2e357cf 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -9,31 +9,21 @@ "statistics": "Statistiche" }, "screens": { - "login": "Accedi", - "register": "Registrati", - "emailVerification": "Verifica Email", - "verificationSuccess": "Verifica Completata", - "home": "Home", - "taskList": "Lista Task", - "categories": "Categorie", - "notes": "Note", - "calendar": "Calendario", - "statistics": "Statistiche", "profile": "Profilo", "settings": "Impostazioni", - "accountSettings": "Gestione Account", - "changePassword": "Cambia Password", - "help": "Aiuto", - "about": "Info", + "accountSettings": "Impostazioni account", + "changePassword": "Cambia password", + "help": "Aiuto e supporto", + "about": "Informazioni sull'app", "language": "Lingua", - "voiceSettings": "Impostazioni Vocali", + "voiceSettings": "Impostazioni vocali", "googleCalendar": "Google Calendar", - "notificationDebug": "Debug Notifiche", - "bugReport": "Segnala Bug", - "notFound": "Pagina non trovata", - "notificationSettings": "Impostazioni Notifiche", - "memorySettings": "Impostazioni Memoria", - "aiSettings": "Impostazioni AI" + "notificationSettings": "Notifiche", + "notificationDebug": "Debug notifiche", + "bugReport": "Segnala un bug", + "memorySettings": "Memoria AI", + "aiSettings": "Impostazioni Intelligenza Artificiale", + "subscriptionPlans": "Pricing" } }, @@ -918,12 +908,17 @@ "upgrade": "Aggiorna piano", "loading": "Caricamento piano...", "error": "Impossibile caricare le informazioni sul piano. Tocca per riprovare.", - "quotaExceeded": "Hai raggiunto il limite mensile di messaggi per il tuo piano {{plan}}. I contatori si resettano il {{date}}.", - "voiceQuotaExceeded": "Hai esaurito le richieste vocali mensili per il piano {{plan}}. Puoi continuare a usare la chat testuale.", + "dailyMessages": "Messaggi giornalieri", + "voiceEnabled": "Attivo", + "voiceDisabled": "Non disponibile", + "voiceMonthlyLimitTitle": "Limite mensile voce raggiunto", + "voiceMonthlyLimitExceeded": "Hai esaurito le richieste vocali mensili. Si azzera il 1° del mese prossimo.", + "quotaExceeded": "Hai raggiunto il limite giornaliero di messaggi per il tuo piano {{plan}}. Si azzera a mezzanotte UTC.", + "voiceQuotaExceeded": "La chat vocale non è disponibile nel tuo piano. Fai l'upgrade per sbloccarla.", "goToPlan": "Vai al piano", - "remaining": "{{n}} messaggi rimasti questo mese", - "remainingWarning": "Ti restano solo {{n}} messaggi questo mese", - "quotaZero": "Limite mensile raggiunto" + "remaining": "{{n}} messaggi rimasti oggi", + "remainingWarning": "Ti restano solo {{n}} messaggi oggi", + "quotaZero": "Limite giornaliero raggiunto" }, "memorySettings": { "loading": "Caricamento memorie...", @@ -962,5 +957,58 @@ "deleteFailed": "Impossibile eliminare la memoria. Verifica l'ID e riprova.", "deleteAllFailed": "Impossibile eliminare tutte le memorie. Riprova più tardi." } + }, + "subscriptionPlans": { + "description": "Scegli il piano che si adatta alle tue esigenze. Fai l'upgrade quando vuoi da Impostazioni.", + "currentPlan": "Piano attuale", + "upgradeTo": "Passa a {{plan}}", + "unavailable": "Non disponibile", + "features": { + "chatTextDaily": "Messaggi testuali giornalieri", + "chatTextMonthly": "Messaggi testuali mensili", + "chatVoiceDaily": "Messaggi vocali giornalieri", + "chatVoiceMonthly": "Messaggi vocali mensili", + "aiModel": "Modello AI", + "maxCategories": "Categorie" + }, + "purchaseSuccess": "Acquisto completato!", + "purchaseError": "Acquisto non riuscito. Riprova.", + "cancelConfirm": "Annullare abbonamento?", + "cancelConfirmMessage": "Il tuo abbonamento rimarrà attivo fino alla fine del periodo di fatturazione corrente.", + "cancelSuccess": "Abbonamento annullato con successo", + "cancelError": "Impossibile annullare l'abbonamento. Riprova.", + "cancelSubscription": "Annulla abbonamento", + "gracePeriod": "Il tuo accesso rimane attivo fino al {{date}}", + "restoreSuccess": "Acquisti ripristinati con successo!", + "restoreError": "Impossibile ripristinare gli acquisti", + "noPurchasesFound": "Nessun acquisto da ripristinare", + "restorePurchases": "Ripristina acquisti", + "monthly": "Mensile", + "annual": "Annuale", + "savePercent": "Risparmia il {{percent}}%", + "freeTrial": "Prova gratuita", + "freeTrialDuration": "{{days}} giorni di prova gratuita", + "perMonth": "/mese", + "perYear": "/anno", + "thenPrice": "poi {{price}}", + "perMonthShort": "/mese", + "perYearShort": "/anno", + "selectPlan": "Seleziona piano", + "active": "Attivo", + "topFeatures": "Funzionalità principali", + "featTextTitle": "Chat Testuali", + "featTextDesc": "Include {{limits}}", + "featVoiceTitle": "Interazioni Vocali", + "featVoiceDesc": "Include {{limits}}", + "featAiTitle": "Intelligenza AI", + "featAiDesc": "Basata sul modello {{model}} per risposte accurate", + "featCategoriesTitle": "Organizzazione", + "featCategoriesDesc": "Organizza i task in fino a {{num}} categorie", + "freeDescription": "Solo le basi", + "premiumDesc": "Sblocca tutte le funzionalità premium", + "offlineDescription": "Dettagli non disponibili al momento.", + "offlineWarning": "Offline. Prezzi non disponibili.", + "offlineError": "Servizio offline o piano non disponibile.", + "getPlan": "Ottieni {{plan}}" } } diff --git a/src/navigation/index.tsx b/src/navigation/index.tsx index 5ffc33c..ad5d324 100644 --- a/src/navigation/index.tsx +++ b/src/navigation/index.tsx @@ -39,6 +39,7 @@ import MemorySettingsScreen from "./screens/MemorySettings"; import AISettingsScreen from "./screens/AISettings"; import RecurringTasksScreen from "./screens/RecurringTasksScreen"; import CalendarScreen from "./screens/Calendar"; +import SubscriptionPlansScreen from "./screens/SubscriptionPlans"; import NotificationDebugScreen from "./screens/NotificationDebug"; import BugReportScreen from "./screens/BugReport"; //import StatisticsScreen from "./screens/Statistics"; @@ -66,7 +67,7 @@ export type RootStackParamList = { VerificationSuccess: { email: string; username: string; password: string }; HomeTabs: undefined; // Contiene il Tab Navigator Home20: undefined; // Nuova schermata Home2.0 - TaskList: { category_name: number | string }; + TaskList: { categoryId?: number | string; category_name: number | string; isOwned?: boolean; permissionLevel?: "READ_ONLY" | "READ_WRITE" }; Profile: undefined; Settings: undefined; AccountSettings: undefined; @@ -86,6 +87,7 @@ export type RootStackParamList = { MemorySettings: undefined; AISettings: undefined; RecurringTasks: undefined; + SubscriptionPlans: undefined; }; // Definizione del tipo per le route dei Tab @@ -134,7 +136,7 @@ function HomeTabs() { return ; }, - tabBarActiveTintColor: "#007AFF", + tabBarActiveTintColor: "#000000", tabBarInactiveTintColor: "gray", headerShown: false, })} @@ -191,17 +193,17 @@ function NavigationHandler() { return false; // Lascia che React Navigation gestisca il back button }; - // Listener per sincronizzazione automatica al cambio schermata - const handleScreenChange = async ({ screenName, params }) => { - console.log(`[NAVIGATION] 🔄 Cambio schermata rilevato: ${screenName}`); + // Listener per sincronizzazione automatica solo su schermate che ne hanno bisogno + const SYNC_SCREEN = ['Categories', 'Calendar20', 'Calendar']; + const handleScreenChange = async ({ screenName, params }: { screenName: string; params: any }) => { + if (!SYNC_SCREEN.includes(screenName)) return; - // Avvia sincronizzazione asincrona (non bloccante) syncAllData() .then(({ tasks, categories }) => { - console.log(`[NAVIGATION] ✅ Sincronizzazione automatica completata per ${screenName}: ${tasks.length} task, ${categories.length} categorie`); + console.log(`[SYNC] Sync completata per ${screenName}: ${tasks.length} task, ${categories.length} categorie`); }) .catch((error) => { - console.log(`[NAVIGATION] ⚠️ Sincronizzazione fallita per ${screenName}:`, error.message); + console.log(`[SYNC] Fallita per ${screenName}:`, error.message); }); }; @@ -226,7 +228,6 @@ function NavigationHandler() { if (state) { const currentRoute = state.routes[state.index]; if (currentRoute) { - console.log(`[NAVIGATION] 📱 Navigazione verso: ${currentRoute.name}`); emitScreenChange(currentRoute.name, currentRoute.params); // ── Analytics: traccia navigazione ── trackScreenView(currentRoute.name); @@ -443,17 +444,23 @@ function AppStack() { ({ title: String(route.params?.category_name || '') })} /> + diff --git a/src/navigation/screens/AISettings.tsx b/src/navigation/screens/AISettings.tsx index a86f2cb..a07f8c1 100644 --- a/src/navigation/screens/AISettings.tsx +++ b/src/navigation/screens/AISettings.tsx @@ -50,36 +50,33 @@ export default function AISettings() { loadPlan(); }, [loadPlan]); + // Se il piano è free e il modello era impostato su advanced, forza base + useEffect(() => { + if (!planData) return; + if (planData.effective_plan.toLowerCase() === 'free' && model === 'advanced') { + setModel('base'); + AsyncStorage.setItem(AI_MODEL_KEY, 'base'); + Alert.alert( + 'Modello avanzato non disponibile', + 'Il modello avanzato non è incluso nel piano Free. Hai impostato automaticamente il modello Base.', + [{ text: 'OK' }] + ); + } + }, [planData]); // eslint-disable-line react-hooks/exhaustive-deps + const handleModelSelect = async (tier: ModelTier) => { + if (tier === 'advanced' && planData?.effective_plan.toLowerCase() === 'free') { + Alert.alert( + 'Modello non disponibile', + 'Il modello avanzato non è disponibile nel piano Free. Usa il modello Base oppure fai l\'upgrade.', + [{ text: 'OK' }] + ); + return; + } setModel(tier); await AsyncStorage.setItem(AI_MODEL_KEY, tier); }; - const formatResetDate = (dateStr: string): string => { - try { - const date = new Date(dateStr + 'T00:00:00'); - return date.toLocaleDateString(undefined, { day: 'numeric', month: 'long', year: 'numeric' }); - } catch { - return dateStr; - } - }; - - const renderProgressBar = (used: number, limit: number) => { - const fraction = limit > 0 ? Math.min(used / limit, 1) : 0; - const isNearLimit = fraction >= 0.8; - return ( - - - - ); - }; - const renderPlanSection = () => { if (planLoading) { return ( @@ -98,48 +95,42 @@ export default function AISettings() { ); } - const textUnlimited = isUnlimitedPlan(planData.text_messages_limit); - const voiceUnlimited = isUnlimitedPlan(planData.voice_requests_limit); + const textUnlimited = isUnlimitedPlan(planData.chat_text_daily_limit); return ( - {planData.plan} + {planData.effective_plan.toUpperCase()} - - {t('planUsage.resetsOn', { date: formatResetDate(planData.reset_date) })} - - {t('planUsage.textMessages')} + {t('planUsage.dailyMessages')} {textUnlimited ? t('planUsage.unlimited') - : `${planData.text_messages_used} / ${planData.text_messages_limit}`} + : String(planData.chat_text_daily_limit)} - {!textUnlimited && renderProgressBar(planData.text_messages_used, planData.text_messages_limit)} {t('planUsage.voiceRequests')} - {voiceUnlimited + {planData.chat_voice_monthly_limit === null ? t('planUsage.unlimited') - : `${planData.voice_requests_used} / ${planData.voice_requests_limit}`} + : String(planData.chat_voice_monthly_limit)} - {!voiceUnlimited && renderProgressBar(planData.voice_requests_used, planData.voice_requests_limit)} - {planData.plan === 'FREE' && ( + {planData.effective_plan.toLowerCase() === 'free' && ( Alert.alert(t('planUsage.upgrade'), 'Coming soon!')} + onPress={() => navigation.navigate('SubscriptionPlans')} > {t('planUsage.upgrade')} @@ -168,25 +159,31 @@ export default function AISettings() { {(['base', 'advanced'] as ModelTier[]).map((tier) => { const isSelected = model === tier; + const isLocked = tier === 'advanced' && planData?.effective_plan.toLowerCase() === 'free'; return ( handleModelSelect(tier)} - activeOpacity={0.7} + activeOpacity={isLocked ? 0.6 : 0.7} > - - {isSelected && } + + {isSelected && !isLocked && } - + {t(`aiSettings.model.${tier}.label`)} - {t(`aiSettings.model.${tier}.desc`)} + + {t(`aiSettings.model.${tier}.desc`)} + - {isSelected && } + {isLocked + ? + : isSelected && + } ); })} @@ -344,12 +341,25 @@ const styles = StyleSheet.create({ modelLabelSelected: { fontWeight: '600', }, + modelRowLocked: { + backgroundColor: '#fafafa', + }, + radioDotLocked: { + borderColor: '#cccccc', + backgroundColor: '#f0f0f0', + }, + modelLabelLocked: { + color: '#aaaaaa', + }, modelDesc: { fontSize: 13, color: '#6c757d', fontFamily: 'System', marginTop: 2, }, + modelDescLocked: { + color: '#cccccc', + }, // Plan & Usage card planCardWrapper: { diff --git a/src/navigation/screens/Calendar.tsx b/src/navigation/screens/Calendar.tsx index 2b88a3e..b6daf41 100644 --- a/src/navigation/screens/Calendar.tsx +++ b/src/navigation/screens/Calendar.tsx @@ -2,7 +2,6 @@ import React, { useState, useEffect } from 'react'; import { View, StyleSheet, - Text, TouchableOpacity } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; @@ -12,6 +11,8 @@ import AsyncStorage from '@react-native-async-storage/async-storage'; import CalendarView from '../../components/Calendar/CalendarView'; import Calendar20View from '../../components/Calendar20/Calendar20View'; import { useTranslation } from 'react-i18next'; +import { AppText } from '../../components/UI/foundation'; +import { colors } from '../../theme/tokens'; const CALENDAR_VIEW_MODE_KEY = '@calendar_view_mode'; @@ -62,7 +63,7 @@ export default function Calendar() { {/* Header con titolo principale e toggle button */} - {t('calendar.title')} + {t('calendar.title')} @@ -86,7 +87,7 @@ export default function Calendar() { const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: '#ffffff', + backgroundColor: colors.background, }, header: { paddingTop: 4, @@ -98,11 +99,7 @@ const styles = StyleSheet.create({ }, mainTitle: { paddingTop: 2, - fontSize: 30, - fontWeight: "200", // Stesso peso di Home20 - color: "#000000", textAlign: "left", - fontFamily: "System", letterSpacing: -1.5, marginBottom: 0, flex: 1, diff --git a/src/navigation/screens/Categories.tsx b/src/navigation/screens/Categories.tsx index 737147e..b613c1a 100644 --- a/src/navigation/screens/Categories.tsx +++ b/src/navigation/screens/Categories.tsx @@ -1,11 +1,11 @@ -import React, { useRef, useState } from "react"; +import React, { useRef, useState, useCallback } from "react"; import { View, StyleSheet, - Text, TouchableOpacity, + ScrollView, + RefreshControl, } from "react-native"; -import { SafeAreaView } from 'react-native-safe-area-context'; import { StatusBar } from 'expo-status-bar'; import { useFocusEffect } from "@react-navigation/native"; import CategoryList from "../../components/Category/CategoryList"; @@ -13,7 +13,13 @@ import AddCategoryButton from "../../components/Category/AddCategoryButton"; import SearchTasksButton from "../../components/UI/SearchTasksButton"; import GlobalTaskSearch from "../../components/Task/GlobalTaskSearch"; import { useTranslation } from "react-i18next"; -import { MaterialIcons } from "@expo/vector-icons"; +import { TaskCacheService } from "../../services/TaskCacheService"; +import SyncManager from "../../services/SyncManager"; +import { + ContentContainer, + ScreenContainer, + ScreenHeader, +} from "../../components/UI/foundation"; export default function Categories() { const { t } = useTranslation(); @@ -22,17 +28,29 @@ export default function Categories() { hardReload: () => void; } | null>(null); const [searchModalVisible, setSearchModalVisible] = useState(false); + const [refreshing, setRefreshing] = useState(false); - // Ricarica le categorie quando la schermata viene visualizzata useFocusEffect( React.useCallback(() => { if (categoryListRef.current) { - // Silent refresh on focus categoryListRef.current.reloadCategories(true); } }, []) ); + const onRefresh = useCallback(async () => { + setRefreshing(true); + try { + await TaskCacheService.getInstance().clearCache(); + await SyncManager.getInstance().startSync(); + if (categoryListRef.current) { + categoryListRef.current.hardReload(); + } + } finally { + setRefreshing(false); + } + }, []); + const handleCategoryAdded = () => { if (categoryListRef.current) { categoryListRef.current.reloadCategories(); @@ -47,70 +65,54 @@ export default function Categories() { setSearchModalVisible(false); }; - const handleReload = () => { - if (categoryListRef.current) { - categoryListRef.current.hardReload(); - } - }; - return ( - + - {/* Header con titolo principale - stesso stile di Home20 */} - - {t("categories.title")} - + - - - - - - - - + } + > + + + + + + - + - + ); } const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: "#ffffff", - }, - header: { - paddingHorizontal: 15, - paddingBottom: 0, - flexDirection: "row", - alignItems: "flex-start", - }, - mainTitle: { - fontSize: 30, - fontWeight: "200", // Stesso peso di Home20 - color: "#000000", - textAlign: "left", - fontFamily: "System", - letterSpacing: -1.5, - marginBottom: 0, - paddingBottom: 5, - - }, content: { flex: 1, }, + scrollContent: { + flexGrow: 1, + }, searchContainer: { flexDirection: "row", alignItems: "center", diff --git a/src/navigation/screens/Home.tsx b/src/navigation/screens/Home.tsx index 3580c64..0855952 100644 --- a/src/navigation/screens/Home.tsx +++ b/src/navigation/screens/Home.tsx @@ -34,6 +34,8 @@ import VoiceCalendarModal from "../../components/BotChat/VoiceCalendarModal"; import { useTranslation } from 'react-i18next'; import { ChatHistory } from "../../components/BotChat/ChatHistory"; import { useTutorialContext } from "../../contexts/TutorialContext"; +import { AppText } from "../../components/UI/foundation"; +import { colors } from "../../theme/tokens"; const HomeScreen = () => { const { t } = useTranslation(); @@ -679,19 +681,19 @@ const HomeScreen = () => { return ( - + {/* Header con titolo principale e indicatori sync */} - Mytaskly + Mytaskly - + { {chatStarted && !showChatHistory && ( @@ -710,7 +712,7 @@ const HomeScreen = () => { onPress={handleResetChat} activeOpacity={0.7} > - + )} @@ -970,11 +972,11 @@ const HomeScreen = () => { const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: "#ffffff", + backgroundColor: colors.background, }, keyboardAwareScrollView: { flex: 1, - backgroundColor: "#ffffff", + backgroundColor: colors.background, }, scrollViewContent: { flexGrow: 1, @@ -1009,9 +1011,7 @@ const styles = StyleSheet.create({ padding: 8, }, mainTitle: { - fontSize: 28, - fontWeight: "200", - color: "#000000", + color: colors.textPrimary, fontFamily: "System", letterSpacing: -1.5, }, @@ -1038,7 +1038,7 @@ const styles = StyleSheet.create({ cursorText: { fontSize: 34, fontWeight: "300", - color: "#000000", + color: colors.textPrimary, fontFamily: "System", letterSpacing: -0.8, }, @@ -1054,7 +1054,7 @@ const styles = StyleSheet.create({ greetingText: { fontSize: Platform.select({ ios: 28, android: 26 }), fontWeight: "300", - color: "#000000", + color: colors.textPrimary, textAlign: "center", lineHeight: Platform.select({ ios: 36, android: 34 }), fontFamily: "System", @@ -1070,7 +1070,7 @@ const styles = StyleSheet.create({ alignItems: "flex-start", }, loadingBubble: { - backgroundColor: "#f0f0f0", + backgroundColor: colors.borderSoft, paddingHorizontal: 15, paddingVertical: 12, borderRadius: 16, @@ -1085,7 +1085,7 @@ const styles = StyleSheet.create({ }, loadingText: { fontSize: 16, - color: "#666666", + color: colors.textSecondary, marginRight: 8, fontFamily: "System", }, @@ -1097,7 +1097,7 @@ const styles = StyleSheet.create({ width: 6, height: 6, borderRadius: 3, - backgroundColor: "#999999", + backgroundColor: colors.textTertiary, marginHorizontal: 2, }, inputSection: { @@ -1105,14 +1105,14 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, paddingBottom: 20, paddingTop: 12, - backgroundColor: "#ffffff", + backgroundColor: colors.background, borderTopWidth: 1, - borderTopColor: "#e1e5e9", + borderTopColor: colors.border, }, inputContainer: { flexDirection: "row", alignItems: "center", - backgroundColor: "#ffffff", + backgroundColor: colors.background, borderRadius: 30, paddingHorizontal: 14, paddingVertical: 8, @@ -1120,7 +1120,7 @@ const styles = StyleSheet.create({ maxWidth: 420, minHeight: 50, borderWidth: 1.5, - borderColor: "#e1e5e9", + borderColor: colors.border, shadowColor: "#000", shadowOffset: { width: 0, @@ -1137,7 +1137,7 @@ const styles = StyleSheet.create({ textInput: { flex: 1, fontSize: 15, - color: "#000000", + color: colors.textPrimary, fontFamily: "System", fontWeight: "400", minHeight: 36, @@ -1148,7 +1148,7 @@ const styles = StyleSheet.create({ textInputSingleLine: { flex: 1, fontSize: 15, - color: "#000000", + color: colors.textPrimary, fontFamily: "System", fontWeight: "400", height: 36, @@ -1171,7 +1171,7 @@ const styles = StyleSheet.create({ paddingVertical: 12, borderRadius: 25, borderWidth: 1, - borderColor: "#e1e5e9", + borderColor: colors.border, shadowColor: "#000", shadowOffset: { width: 0, @@ -1196,7 +1196,7 @@ const styles = StyleSheet.create({ }, chatHistoryContainer: { flex: 1, - backgroundColor: "#ffffff", + backgroundColor: colors.background, }, }); diff --git a/src/navigation/screens/Login.tsx b/src/navigation/screens/Login.tsx index ce2d1aa..353ecb5 100644 --- a/src/navigation/screens/Login.tsx +++ b/src/navigation/screens/Login.tsx @@ -105,7 +105,10 @@ const LoginScreen = () => { React.useEffect(() => { if (loginSuccess) { const timer = setTimeout(() => { - navigation.navigate("HomeTabs"); + navigation.reset({ + index: 0, + routes: [{ name: "HomeTabs" }], + }); setLoginSuccess(false); }, 3500); return () => clearTimeout(timer); diff --git a/src/navigation/screens/NotificationDebug.tsx b/src/navigation/screens/NotificationDebug.tsx index 341c13a..9b03812 100644 --- a/src/navigation/screens/NotificationDebug.tsx +++ b/src/navigation/screens/NotificationDebug.tsx @@ -12,19 +12,48 @@ import { StatusBar } from 'expo-status-bar'; import { useNavigation } from '@react-navigation/native'; import { Ionicons } from '@expo/vector-icons'; import { NotificationManager } from '../../components/Debug/NotificationManager'; -import { sendTestNotification, getAllScheduledNotifications } from '../../services/notificationService'; +import { + sendTestNotification, + getAllScheduledNotifications, + registerForPushNotificationsAsync, + sendTokenToBackend +} from '../../services/notificationService'; import { useTaskNotifications } from '../../services/taskNotificationService'; export default function NotificationDebugScreen() { const navigation = useNavigation(); const [isLoading, setIsLoading] = useState(false); const [scheduledCount, setScheduledCount] = useState(0); + const [expoToken, setExpoToken] = useState(null); const { scheduleTaskNotification } = useTaskNotifications(); const handleGoBack = () => { navigation.goBack(); }; + const handleForceTokenSync = async () => { + setIsLoading(true); + try { + const token = await registerForPushNotificationsAsync(); + if (token) { + setExpoToken(token); + const success = await sendTokenToBackend(token, true); + if (success) { + Alert.alert('✅ Successo', 'Token aggiornato e forzato al server Firebase correttamente!'); + } else { + Alert.alert('⚠️ Attenzione', 'Token ottenuto ma non inviato al server. Controlla la connessione.'); + } + } else { + Alert.alert('❌ Errore', 'Impossibile ottenere il token da Expo.'); + } + } catch (e) { + console.error(e); + Alert.alert('❌ Errore', 'Operazione fallita.'); + } finally { + setIsLoading(false); + } + }; + const handleSendTestNotification = async () => { setIsLoading(true); try { @@ -85,16 +114,8 @@ export default function NotificationDebugScreen() { }, []); return ( - + - - {/* Header */} - - - - - Debug Notifiche - {/* Content */} @@ -120,6 +141,30 @@ export default function NotificationDebugScreen() { 🧪 Test Rapido + + + {isLoading ? '⏳ Caricamento...' : '🔄 Forza rigenerazione & Invio Token a Firebase'} + + + + {expoToken && ( + + + Token Expo (Tieni premuto per selezionare e copiare): + + + {expoToken} + + + )} + >(); @@ -53,97 +56,94 @@ export default function Settings() { } }; - const formatResetDate = (dateStr: string): string => { + const handleTestNotification = async () => { try { - const date = new Date(dateStr + 'T00:00:00'); - return date.toLocaleDateString(undefined, { day: 'numeric', month: 'long', year: 'numeric' }); - } catch { - return dateStr; + const token = await getValidToken(); + const userId = await AsyncStorage.getItem(STORAGE_KEYS.USER_ID); + if (!token || !userId) { + Alert.alert('Errore', 'Utente non autenticato'); + return; + } + const scheduledAt = new Date(Date.now() + 60_000).toISOString(); + await axiosInstance.post('/notifications/test-notification', { + user_id: parseInt(userId, 10), + title: 'Test notifica', + body: 'Se ricevi questo messaggio, le notifiche funzionano!', + scheduled_at: scheduledAt, + }, { + headers: { Authorization: `Bearer ${token}` }, + }); + Alert.alert('Ok', 'Notifica programmata tra 1 minuto'); + } catch (error: any) { + Alert.alert('Errore', error?.response?.data?.detail || 'Invio fallito'); } }; - const renderProgressBar = (used: number, limit: number) => { - const fraction = limit > 0 ? Math.min(used / limit, 1) : 0; - const isNearLimit = fraction >= 0.8; - return ( - - - - ); - }; - const renderPlanSection = () => { if (planLoading) { return ( - - - {t('planUsage.loading')} + + + {t('planUsage.loading', 'Caricamento...')} ); } if (planError || !planData) { return ( - - {t('planUsage.error')} + + {t('planUsage.error', 'Errore di caricamento. Riprova.')} ); } - const textUnlimited = isUnlimitedPlan(planData.text_messages_limit); - const voiceUnlimited = isUnlimitedPlan(planData.voice_requests_limit); + const isFree = planData.effective_plan.toLowerCase() === 'free'; + const textUnlimited = isUnlimitedPlan(planData.chat_text_daily_limit); + const voiceUnlimited = isUnlimitedPlan(planData.chat_voice_monthly_limit); return ( - - {/* Plan badge */} - - - {planData.plan} + + + + {!isFree && } + + {planData.effective_plan.toUpperCase()} + - - {t('planUsage.resetsOn', { date: formatResetDate(planData.reset_date) })} - - - - {/* Text messages */} - - - {t('planUsage.textMessages')} - - {textUnlimited - ? t('planUsage.unlimited') - : `${planData.text_messages_used} / ${planData.text_messages_limit}`} + + + + {t('settings.plan.active', 'Active')} - {!textUnlimited && renderProgressBar(planData.text_messages_used, planData.text_messages_limit)} - {/* Voice requests */} - - - {t('planUsage.voiceRequests')} - - {voiceUnlimited - ? t('planUsage.unlimited') - : `${planData.voice_requests_used} / ${planData.voice_requests_limit}`} + + + + {t('planUsage.dailyMessages', 'Chat testuali (Giorno)')} + + + {textUnlimited ? '∞' : String(planData.chat_text_daily_limit)} + + + + + + {t('planUsage.voiceRequests', 'Chat vocali (Mese)')} + + + {voiceUnlimited ? '∞' : String(planData.chat_voice_monthly_limit)} - {!voiceUnlimited && renderProgressBar(planData.voice_requests_used, planData.voice_requests_limit)} - {/* Upgrade CTA for FREE */} - {planData.plan === 'FREE' && ( + {isFree && ( Alert.alert(t('planUsage.upgrade'), 'Coming soon!')} + style={styles.upgradeBtn} + onPress={() => navigation.navigate('SubscriptionPlans')} > - {t('planUsage.upgrade')} + {t('planUsage.upgrade', 'Upgrade to Premium')} )} @@ -151,10 +151,30 @@ export default function Settings() { }; return ( - + + + + + {/* Plan & Usage Section (MOVED TO TOP) */} + + {t('planUsage.sectionTitle', 'Plan & Usage')} + + + {renderPlanSection()} + + + navigation.navigate('SubscriptionPlans')} + > + + + {t('settings.menu.pricing', 'Pricing & Plans')} + + + - {/* Account Section */} {t('settings.sections.account')} @@ -171,14 +191,6 @@ export default function Settings() { - {/* Plan & Usage Section */} - - {t('planUsage.sectionTitle')} - - - {renderPlanSection()} - - {/* AI Section */} {t('settings.sections.ai')} @@ -260,6 +272,28 @@ export default function Settings() { + + + + Test notifica + + + + + navigation.navigate('NotificationDebug')} + > + + + Debug Notifiche + + + + + ); @@ -302,7 +337,9 @@ const styles = StyleSheet.create({ }, content: { flex: 1, - paddingTop: 20, + }, + scrollContent: { + paddingBottom: 40, }, menuItem: { flexDirection: 'row', @@ -337,99 +374,122 @@ const styles = StyleSheet.create({ color: '#000000', fontFamily: 'System', }, - // Plan & Usage card + + // Premium Plan Card styles planCardWrapper: { paddingHorizontal: 20, + marginBottom: 8, }, - planCard: { - backgroundColor: '#f8f9fa', - borderRadius: 16, - padding: 16, - borderWidth: 1, - borderColor: '#e1e5e9', + premiumCard: { + borderRadius: 24, + padding: 20, + marginBottom: 8, }, - planLoadingText: { - fontSize: 14, - color: '#666666', - fontFamily: 'System', - marginTop: 8, - textAlign: 'center', + premiumCardFree: { + backgroundColor: '#ffffff', + borderWidth: 1, + borderColor: '#e5e5ea', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.05, + shadowRadius: 8, + elevation: 2, }, - planErrorText: { - fontSize: 14, - color: '#666666', - fontFamily: 'System', - textAlign: 'center', + premiumCardActive: { + backgroundColor: '#1C1C1E', + shadowColor: '#000', + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.2, + shadowRadius: 12, + elevation: 6, }, - planBadgeRow: { + premiumCardHeader: { flexDirection: 'row', - alignItems: 'center', justifyContent: 'space-between', - marginBottom: 16, + alignItems: 'center', + marginBottom: 20, + }, + premiumPlanName: { + fontSize: 22, + fontWeight: '800', + color: '#1C1C1E', + letterSpacing: -0.5, }, - planBadge: { - backgroundColor: '#000000', - borderRadius: 8, - paddingHorizontal: 10, + textWhite: { + color: '#FFFFFF', + }, + textGrayLight: { + color: '#A1A1A6', + }, + activeBadge: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#F0F8FF', + paddingHorizontal: 8, paddingVertical: 4, + borderRadius: 12, }, - planBadgeText: { - color: '#ffffff', - fontSize: 12, - fontWeight: '600', - fontFamily: 'System', - letterSpacing: 0.5, + activeBadgeDark: { + backgroundColor: '#FFFFFF', }, - resetDateText: { + activeBadgeText: { fontSize: 12, - color: '#666666', - fontFamily: 'System', + fontWeight: '700', + color: '#007AFF', + marginLeft: 4, }, - usageRow: { - marginBottom: 12, + activeBadgeTextDark: { + color: '#1C1C1E', }, - usageLabelRow: { + usageContainer: { flexDirection: 'row', - justifyContent: 'space-between', - marginBottom: 6, + alignItems: 'center', + backgroundColor: 'rgba(142, 142, 147, 0.1)', + borderRadius: 16, + padding: 16, }, - usageLabel: { - fontSize: 14, - color: '#333333', - fontFamily: 'System', - fontWeight: '400', + usageItem: { + flex: 1, }, - usageCount: { - fontSize: 14, - color: '#333333', - fontFamily: 'System', + usageLabel: { + fontSize: 13, + color: '#8E8E93', + marginBottom: 4, fontWeight: '500', }, - progressBarTrack: { - height: 6, - backgroundColor: '#e1e5e9', - borderRadius: 3, - overflow: 'hidden', + usageValue: { + fontSize: 18, + fontWeight: '700', + color: '#1C1C1E', }, - progressBarFill: { - height: 6, - backgroundColor: '#000000', - borderRadius: 3, + usageDivider: { + width: 1, + height: '100%', + backgroundColor: 'rgba(142, 142, 147, 0.2)', + marginHorizontal: 16, }, - progressBarWarning: { - backgroundColor: '#FF6B35', - }, - upgradeButton: { - marginTop: 12, - backgroundColor: '#000000', - borderRadius: 12, - paddingVertical: 10, + upgradeBtn: { + backgroundColor: '#1C1C1E', + borderRadius: 100, + paddingVertical: 12, alignItems: 'center', + marginTop: 16, }, - upgradeButtonText: { - color: '#ffffff', - fontSize: 14, - fontWeight: '600', - fontFamily: 'System', + upgradeBtnText: { + color: '#FFFFFF', + fontSize: 15, + fontWeight: '700', + }, + planLoadingText: { + marginTop: 12, + textAlign: 'center', + color: '#8E8E93', + fontWeight: '500', }, + planErrorText: { + textAlign: 'center', + color: '#FF3B30', + fontWeight: '500', + paddingVertical: 10, + } }); diff --git a/src/navigation/screens/SubscriptionPlans.tsx b/src/navigation/screens/SubscriptionPlans.tsx new file mode 100644 index 0000000..ff00e92 --- /dev/null +++ b/src/navigation/screens/SubscriptionPlans.tsx @@ -0,0 +1,641 @@ +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { + StyleSheet, + View, + Text, + TouchableOpacity, + ScrollView, + SafeAreaView, + Alert, + ActivityIndicator, + Platform, +} from 'react-native'; +import { StatusBar } from 'expo-status-bar'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; +import { + getUserPlan, + UserSubscription, + isUnlimitedPlan, +} from '../../services/planService'; +import RevenueCatService, { + type Offerings, + type PurchasesPackage, +} from '../../services/revenueCatService'; +import { PLANS, Plan, BillingPeriod } from '../../constants/planLimits'; + +export default function SubscriptionPlans() { + const { t } = useTranslation(); + + const [planData, setPlanData] = useState(null); + const [offerings, setOfferings] = useState(null); + const [loading, setLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(false); + const [selectedPlanId, setSelectedPlanId] = useState('free'); + const [billingPeriod, setBillingPeriod] = useState('monthly'); + + const loadData = useCallback(async () => { + try { + setLoading(true); + const [userPlan, rcOfferings] = await Promise.all([ + getUserPlan(), + RevenueCatService.getInstance().getOfferings(), + ]); + setPlanData(userPlan); + setOfferings(rcOfferings); + + if (userPlan?.effective_plan) { + setSelectedPlanId(userPlan.effective_plan); + } + } catch (error) { + console.error('Failed to load subscription data:', error); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadData(); + }, [loadData]); + + const handlePurchase = useCallback( + async (plan: Plan, period: BillingPeriod) => { + if (plan.id === 'free') return; + + const productId = plan.getProductId(period); + if (!productId || !offerings) { + Alert.alert( + t('common.messages.error', 'Errore'), + t('subscriptionPlans.offlineError', 'Servizio offline o piano non disponibile.') + ); + return; + } + + try { + setActionLoading(true); + + const offering = offerings.all[plan.id]; + const packageToPurchase = offering?.availablePackages.find( + (pkg: PurchasesPackage) => pkg.product.identifier === productId + ); + + if (!packageToPurchase) { + Alert.alert(t('subscriptionPlans.unavailable', 'Non disponibile al momento.')); + return; + } + + await RevenueCatService.getInstance().purchasePlan(packageToPurchase); + await loadData(); + Alert.alert(t('subscriptionPlans.purchaseSuccess', 'Acquisto completato con successo!')); + } catch (error: any) { + if (error?.userCancelled) { + return; + } + Alert.alert( + t('subscriptionPlans.purchaseError', 'Errore durante l\'acquisto'), + error?.message || t('common.messages.error', 'Si è verificato un errore sconosciuto.') + ); + } finally { + setActionLoading(false); + } + }, + [offerings, t, loadData] + ); + + const isCurrentPlan = useCallback( + (planId: string): boolean => { + return planData?.effective_plan === planId; + }, + [planData] + ); + + const formatFeatureLimit = (daily: number, monthly: number) => { + const d = isUnlimitedPlan(daily) ? '∞' : String(daily); + const m = isUnlimitedPlan(monthly) ? '∞' : String(monthly); + return `${d} ${t('common.daily', 'giornalieri')} • ${m} ${t('common.monthly', 'mensili')}`; + }; + + // Find the package for the selected plan + billing period + const findPackage = useCallback( + (plan: Plan, period: BillingPeriod): PurchasesPackage | undefined => { + const productId = plan.getProductId(period); + if (!productId || !offerings) return undefined; + const offering = offerings.all[plan.id]; + return offering?.availablePackages.find( + (p: PurchasesPackage) => p.product.identifier === productId + ); + }, + [offerings] + ); + + // Calculate annual savings percentage + const calcAnnualSavings = useCallback( + (plan: Plan): number | null => { + const monthlyPkg = findPackage(plan, 'monthly'); + const annualPkg = findPackage(plan, 'annual'); + if (!monthlyPkg || !annualPkg) return null; + + const monthlyCostPerYear = monthlyPkg.product.price * 12; + const annualCost = annualPkg.product.price; + if (monthlyCostPerYear === 0) return null; + + return Math.round(((monthlyCostPerYear - annualCost) / monthlyCostPerYear) * 100); + }, + [findPackage] + ); + + const plansArray = Object.values(PLANS); + const selectedPlan = PLANS[selectedPlanId as keyof typeof PLANS] || PLANS.free; + const isSelectedPlanCurrent = isCurrentPlan(selectedPlan.id); + const isFreeSelected = selectedPlan.id === 'free'; + + const pkg = findPackage(selectedPlan, billingPeriod); + const savings = billingPeriod === 'annual' ? calcAnnualSavings(selectedPlan) : null; + + if (loading) { + return ( + + + + + + + ); + } + + const priceStr = isFreeSelected ? t('common.free', 'Free') : pkg?.product.priceString || '—'; + const periodLabel = billingPeriod === 'annual' + ? t('subscriptionPlans.perYear', '/year') + : t('subscriptionPlans.perMonth', '/month'); + const trialInfo: string | null = (!isFreeSelected && pkg?.product.introductoryPrice) + ? t('subscriptionPlans.freeTrial', 'Free trial') + : null; + + const offlineOrMissing = !isFreeSelected && !pkg; + + return ( + + + + + + {/* Title + Billing Toggle */} + + {t('subscriptionPlans.selectPlan', 'Select plan')} + {!isFreeSelected && ( + + setBillingPeriod('monthly')} + > + + {t('subscriptionPlans.monthly', 'Monthly')} + + + + {savings !== null && savings > 0 && ( + + + {savings}% off + + + )} + setBillingPeriod('annual')} + > + + {t('subscriptionPlans.annual', 'Annual')} + + + + + )} + + + {/* Warning Banner */} + {!loading && !offerings?.all['pro'] && !offerings?.all['premium'] && ( + + + + {t('subscriptionPlans.offlineWarning', 'Offline. Prezzi non disponibili.')} + + + )} + + {/* Plan Tabs */} + + {plansArray.map((p) => { + const isActive = p.id === selectedPlanId; + return ( + setSelectedPlanId(p.id)} + > + + {p.name} + + + ); + })} + + + {/* Dark Plan Header Card */} + + + + {selectedPlan.name} + {trialInfo && !isSelectedPlanCurrent && ( + + + {trialInfo} + + )} + + + + {priceStr} + {isSelectedPlanCurrent && ( + + + {t('subscriptionPlans.active', 'Active')} + + )} + + {!isFreeSelected && pkg && ( + {periodLabel} + )} + + + + + {/* Features Section */} + {t('subscriptionPlans.topFeatures', 'Top features')} + + + + + + + + + + + + + + + + + {/* Bottom Action Button Fixed */} + + handlePurchase(selectedPlan, billingPeriod)} + disabled={isSelectedPlanCurrent || actionLoading || offlineOrMissing || isFreeSelected} + > + {actionLoading ? ( + + ) : ( + + {isSelectedPlanCurrent + ? t('subscriptionPlans.currentPlan', 'Current Plan') + : offlineOrMissing + ? t('subscriptionPlans.unavailable', 'Unavailable') + : isFreeSelected + ? t('subscriptionPlans.currentPlan', 'Current Plan') + : t('subscriptionPlans.getPlan', 'Get {{plan}}', { plan: selectedPlan.name })} + + )} + + + + + ); +} + +function FeatureItem({ icon, title, description, isLast }: { icon: keyof typeof Ionicons.glyphMap, title: string, description: string, isLast?: boolean }) { + return ( + + + + + + {title} + {description} + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#F7F7F9', + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + scrollView: { + flex: 1, + }, + scrollContent: { + paddingHorizontal: 20, + paddingTop: 20, + paddingBottom: 100, + }, + pageTitle: { + fontSize: 34, + fontWeight: '800', + color: '#1C1C1E', + letterSpacing: -0.5, + }, + titleRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 24, + }, + warningBanner: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#fff3cd', + padding: 12, + borderRadius: 12, + marginBottom: 20, + }, + warningText: { + color: '#856404', + fontSize: 14, + marginLeft: 8, + flex: 1, + fontWeight: '500', + }, + tabsContainer: { + flexDirection: 'row', + marginBottom: 16, + alignItems: 'center', + }, + tabButton: { + paddingVertical: 10, + paddingHorizontal: 16, + borderRadius: 20, + marginRight: 8, + }, + tabButtonActive: { + backgroundColor: '#FFFFFF', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.08, + shadowRadius: 6, + elevation: 2, + }, + tabText: { + fontSize: 16, + fontWeight: '600', + color: '#8E8E93', + }, + tabTextActive: { + color: '#1C1C1E', + }, + billingToggle: { + flexDirection: 'row', + backgroundColor: '#E5E5EA', + borderRadius: 12, + padding: 3, + }, + billingOption: { + paddingVertical: 10, + paddingHorizontal: 20, + borderRadius: 10, + flexDirection: 'row', + alignItems: 'center', + position: 'relative', + }, + billingOptionActive: { + backgroundColor: '#FFFFFF', + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.08, + shadowRadius: 4, + elevation: 1, + }, + billingOptionText: { + fontSize: 15, + fontWeight: '600', + color: '#8E8E93', + }, + billingOptionTextActive: { + color: '#1C1C1E', + }, + savingsBadge: { + backgroundColor: '#34C759', + borderRadius: 6, + paddingHorizontal: 5, + paddingVertical: 2, + position: 'absolute', + top: -8, + right: -4, + zIndex: 1, + }, + savingsBadgeText: { + color: '#FFFFFF', + fontSize: 10, + fontWeight: '700', + }, + annualOptionWrapper: { + position: 'relative', + }, + darkCard: { + backgroundColor: '#1C1C1E', + borderRadius: 24, + padding: 24, + marginBottom: 32, + shadowColor: '#000', + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.2, + shadowRadius: 16, + elevation: 8, + }, + darkCardTopRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + darkCardLeft: { + flex: 1, + gap: 10, + }, + darkCardRight: { + alignItems: 'flex-end', + }, + darkCardPriceRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, + }, + darkCardTitle: { + fontSize: 28, + fontWeight: '800', + color: '#FFFFFF', + letterSpacing: -0.5, + }, + activeBadge: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#FFFFFF', + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 100, + alignSelf: 'flex-start', + }, + activeBadgeText: { + fontSize: 12, + fontWeight: '700', + color: '#1C1C1E', + marginLeft: 4, + }, + trialBadge: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(52, 199, 89, 0.15)', + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 100, + alignSelf: 'flex-start', + }, + trialBadgeText: { + color: '#34C759', + fontSize: 12, + fontWeight: '600', + marginLeft: 4, + }, + darkCardPrice: { + fontSize: 28, + fontWeight: '800', + color: '#FFFFFF', + letterSpacing: -0.5, + }, + darkCardPeriod: { + fontSize: 14, + color: '#A1A1A6', + fontWeight: '500', + marginTop: 2, + }, + sectionTitle: { + fontSize: 20, + fontWeight: '700', + color: '#1C1C1E', + marginBottom: 16, + letterSpacing: -0.3, + }, + featuresCard: { + backgroundColor: '#FFFFFF', + borderRadius: 24, + paddingHorizontal: 20, + paddingVertical: 10, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.04, + shadowRadius: 12, + elevation: 2, + }, + featureItemContainer: { + flexDirection: 'row', + paddingVertical: 20, + }, + featureItemBorder: { + borderBottomWidth: 1, + borderBottomColor: '#F2F2F7', + }, + featureIconContainer: { + width: 32, + alignItems: 'flex-start', + justifyContent: 'flex-start', + paddingTop: 2, + }, + featureTextContainer: { + flex: 1, + }, + featureTitle: { + fontSize: 17, + fontWeight: '700', + color: '#1C1C1E', + marginBottom: 6, + letterSpacing: -0.2, + }, + featureDescription: { + fontSize: 15, + color: '#8E8E93', + lineHeight: 20, + }, + bottomActionContainer: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + paddingHorizontal: 20, + paddingTop: 16, + paddingBottom: Platform.OS === 'ios' ? 34 : 24, + backgroundColor: '#F7F7F9', + }, + mainButton: { + backgroundColor: '#1C1C1E', + borderRadius: 100, + height: 56, + justifyContent: 'center', + alignItems: 'center', + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.15, + shadowRadius: 12, + elevation: 4, + }, + mainButtonDisabled: { + backgroundColor: '#E5E5EA', + shadowOpacity: 0, + elevation: 0, + }, + mainButtonText: { + color: '#FFFFFF', + fontSize: 17, + fontWeight: '700', + }, + mainButtonTextDisabled: { + color: '#8E8E93', + }, +}); diff --git a/src/services/AppInitializer.ts b/src/services/AppInitializer.ts index 45c52fe..4190339 100644 --- a/src/services/AppInitializer.ts +++ b/src/services/AppInitializer.ts @@ -4,6 +4,7 @@ import StorageManager from './StorageManager'; import { getAllTasks, getCategories } from './taskService'; import { initializeGoogleSignIn } from './googleSignInService'; import { checkAndRefreshAuth } from './authService'; +import { registerForPushNotificationsAsync, sendTokenToBackend } from './notificationService'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { STORAGE_KEYS } from '../constants/authConstants'; @@ -95,6 +96,19 @@ class AppInitializer { console.error('[APP_INIT] Errore sync offline changes:', error) ); } + + // --- INIZIO: ALLINEAMENTO AUTOMATICO TOKEN NOTIFICHE PUSH --- + try { + console.log('[APP_INIT] Verifica integrità e sincronizzazione automatica Push Token...'); + const currentToken = await registerForPushNotificationsAsync(); + if (currentToken) { + await sendTokenToBackend(currentToken, true); + console.log('[APP_INIT] ✅ Sync del token notifica completato con successo durante l\'avvio.'); + } + } catch (pushError) { + console.error('[APP_INIT] ❌ Errore durante sync automatico token notifiche all\'avvio:', pushError); + } + // --- FINE: ALLINEAMENTO AUTOMATICO TOKEN NOTIFICHE PUSH --- } // 6. Inizializza pulizie periodiche diff --git a/src/services/NetworkService.ts b/src/services/NetworkService.ts index f9bda69..a569ae0 100644 --- a/src/services/NetworkService.ts +++ b/src/services/NetworkService.ts @@ -1,5 +1,4 @@ -// Servizio di rete semplice senza dipendenze esterne -// Fallback per quando NetInfo non è disponibile +import { DEFAULT_BASE_URL } from '../constants/authConstants'; interface NetworkState { isConnected: boolean; @@ -12,10 +11,11 @@ class NetworkService { private static instance: NetworkService; private listeners: NetworkChangeCallback[] = []; private currentState: NetworkState = { - isConnected: true, // Assume connesso di default + isConnected: true, isInternetReachable: true }; private testInterval: NodeJS.Timeout | null = null; + private consecutiveFailures = 0; static getInstance(): NetworkService { if (!NetworkService.instance) { @@ -28,39 +28,41 @@ class NetworkService { this.startNetworkMonitoring(); } - // Ottieni stato attuale della rete - async getNetworkState(): Promise { - // Prova a testare la connessione con una richiesta rapida + private async checkBackendHealth(): Promise { try { const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 3000); // 3 sec timeout + const timeoutId = setTimeout(() => controller.abort(), 5000); - const response = await fetch('https://www.google.com/generate_204', { + const response = await fetch(`${DEFAULT_BASE_URL}/support/health`, { method: 'HEAD', cache: 'no-cache', signal: controller.signal }); clearTimeout(timeoutId); - - const isConnected = response.status === 204; - this.updateNetworkState({ - isConnected, - isInternetReachable: isConnected - }); - - return this.currentState; + return response.ok; } catch { - // Fallback: assume offline - this.updateNetworkState({ - isConnected: false, - isInternetReachable: false - }); - - return this.currentState; + return false; } } + async getNetworkState(): Promise { + const isHealthy = await this.checkBackendHealth(); + + if (isHealthy) { + this.consecutiveFailures = 0; + this.updateNetworkState({ isConnected: true, isInternetReachable: true }); + } else { + this.consecutiveFailures++; + // Require 2 consecutive failures before declaring offline + if (this.consecutiveFailures >= 2) { + this.updateNetworkState({ isConnected: false, isInternetReachable: false }); + } + } + + return this.currentState; + } + // Aggiungi listener per cambiamenti di stato rete addNetworkListener(callback: NetworkChangeCallback): () => void { this.listeners.push(callback); @@ -74,23 +76,8 @@ class NetworkService { }; } - // Test rapido di connettività (per uso interno) async isOnline(): Promise { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 2000); // 2 sec timeout - - await fetch('https://www.google.com/generate_204', { - method: 'HEAD', - cache: 'no-cache', - signal: controller.signal - }); - - clearTimeout(timeoutId); - return true; - } catch { - return false; - } + return this.checkBackendHealth(); } private updateNetworkState(newState: NetworkState): void { @@ -141,23 +128,23 @@ class NetworkService { this.listeners = []; } - // Test di connettività con endpoint personalizzato - async testConnectivity(url: string = 'https://www.google.com/generate_204'): Promise { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 5000); // 5 sec timeout - - const response = await fetch(url, { - method: 'HEAD', - cache: 'no-cache', - signal: controller.signal - }); - - clearTimeout(timeoutId); - return response.ok; - } catch { - return false; + async testConnectivity(url?: string): Promise { + if (url) { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + const response = await fetch(url, { + method: 'HEAD', + cache: 'no-cache', + signal: controller.signal + }); + clearTimeout(timeoutId); + return response.ok; + } catch { + return false; + } } + return this.checkBackendHealth(); } // Ottieni stato corrente (sincrono) diff --git a/src/services/analyticsService.ts b/src/services/analyticsService.ts index 1d493f3..89746b8 100644 --- a/src/services/analyticsService.ts +++ b/src/services/analyticsService.ts @@ -27,7 +27,7 @@ const IS_DEV = typeof __DEV__ !== 'undefined' && __DEV__; // Costanti // ───────────────────────────────────────────────────────────── -const VEXO_API_KEY = 'd9ba61c5-0c0d-413f-98d8-e277b45d3d32'; +const VEXO_API_KEY = process.env.EXPO_PUBLIC_VEXO_API_KEY ?? ''; // Nomi evento — usare SEMPRE queste costanti per evitare typo export const ANALYTICS_EVENTS = { diff --git a/src/services/axiosInstance.ts b/src/services/axiosInstance.ts index 2ccaa57..df0471c 100644 --- a/src/services/axiosInstance.ts +++ b/src/services/axiosInstance.ts @@ -3,7 +3,7 @@ import { DEFAULT_BASE_URL } from '../constants/authConstants'; // Crea un'istanza axios separata per evitare cicli di dipendenze const axiosInstance = axios.create({ - baseURL: DEFAULT_BASE_URL, + baseURL: process.env.EXPO_PUBLIC_API_BASE_URL || DEFAULT_BASE_URL, headers: { 'Content-Type': 'application/json', }, diff --git a/src/services/axiosInterceptor.ts b/src/services/axiosInterceptor.ts index e542218..5e311fc 100644 --- a/src/services/axiosInterceptor.ts +++ b/src/services/axiosInterceptor.ts @@ -4,8 +4,7 @@ import { STORAGE_KEYS, API_ENDPOINTS } from '../constants/authConstants'; import { refreshToken } from './authService'; import axios from './axiosInstance'; -// Chiave segreta hardcoded per le API -const API_SECRET_KEY = 'ubHL%At28^{Lm-vx2_>rG\\m.*FR*rCMC%-4jMhk(FV8CpMD_mHhx,;mXUmC/^GHkT@B@^]k9:B+ga3VWqVRUv,C[}@;>BE//Y@bG'; +const API_SECRET_KEY = process.env.EXPO_PUBLIC_API_SECRET_KEY; // Flag per evitare loop infiniti durante il refresh let isRefreshing = false; diff --git a/src/services/googleSignInService.ts b/src/services/googleSignInService.ts index 4d51356..7bdd97c 100644 --- a/src/services/googleSignInService.ts +++ b/src/services/googleSignInService.ts @@ -56,10 +56,16 @@ export const signInWithGoogleServerSide = async () => { authorization_url, 'mytaskly://auth/login', { - showInRecents: true, + showInRecents: false, } ); + // Force dismiss browser from Android back stack + try { + await WebBrowser.dismissBrowser(); + await WebBrowser.coolDownAsync(); + } catch (_) {} + if (result.type === 'success') { console.log('✅ Authorization completed, processing result...'); diff --git a/src/services/planService.ts b/src/services/planService.ts index ccd6367..b460cf0 100644 --- a/src/services/planService.ts +++ b/src/services/planService.ts @@ -1,24 +1,59 @@ +// MyTaskly API Changes — 2026-04-17 +// GET /auth/me · POST /billing/cancel + import axiosInstance from './axiosInstance'; -export interface UserPlan { - plan: string; - text_messages_limit: number; - text_messages_used: number; - voice_requests_limit: number; - voice_requests_used: number; - reset_date: string; // ISO 8601 date, e.g. "2026-05-01" +export interface UserSubscription { + effective_plan: string; + status: 'free' | 'active' | 'cancelled' | 'expired'; + current_period_end: string | null; + chat_text_daily_limit: number | null; // null = unlimited + chat_text_monthly_limit: number | null; // null = unlimited + chat_voice_enabled: boolean; + chat_voice_daily_limit: number | null; // null = unlimited (currently always null) + chat_voice_monthly_limit: number | null; // null = unlimited + ai_model: string; + max_categories: number | null; // null = unlimited +} + +interface AuthMeResponse { + id: string; + email: string; + name: string; + subscription: UserSubscription; +} + +// MyTaskly API Changes — 2026-04-16 +// POST /billing/cancel + +export interface BillingCancelResponse { + effective_plan: string; + status: 'cancelled'; + current_period_end: string; } +// Backward-compat alias — use UserSubscription for new code +export type UserPlan = UserSubscription; + /** - * Fetches the current user's plan and monthly usage from GET /auth/me/plan. + * Fetches the current user's subscription info from GET /auth/me. * Auth token is injected automatically by the axios interceptor. */ -export async function getUserPlan(): Promise { - const response = await axiosInstance.get('/auth/me/plan'); +export async function getUserPlan(): Promise { + const response = await axiosInstance.get('/auth/me'); + return response.data.subscription; +} + +/** + * Cancels the active subscription. Access remains until current_period_end. + * Throws with 400 if no active subscription exists. + */ +export async function cancelSubscription(): Promise { + const response = await axiosInstance.post('/billing/cancel'); return response.data; } -/** Returns true when the plan should be treated as unlimited (ENTERPRISE). */ -export function isUnlimitedPlan(limit: number): boolean { - return limit >= 9999; +/** Returns true when a limit should be treated as unlimited. */ +export function isUnlimitedPlan(limit: number | null | undefined): boolean { + return limit === null || limit === undefined || limit >= 9999; } diff --git a/src/services/revenueCatService.ts b/src/services/revenueCatService.ts new file mode 100644 index 0000000..55c76c7 --- /dev/null +++ b/src/services/revenueCatService.ts @@ -0,0 +1,71 @@ +import Purchases from 'react-native-purchases'; +import { Platform } from 'react-native'; +import Constants from 'expo-constants'; + +type Offerings = Purchases.Offerings; +type PurchasesPackage = Purchases.Package; +type CustomerInfo = Purchases.CustomerInfo; + +class RevenueCatService { + private static instance: RevenueCatService | null = null; + private initialized: boolean = false; + + private constructor() {} + + static getInstance(): RevenueCatService { + if (!RevenueCatService.instance) { + RevenueCatService.instance = new RevenueCatService(); + RevenueCatService.instance.init(); + } + return RevenueCatService.instance; + } + + private init(): void { + if (this.initialized) return; + if (Platform.OS !== 'android') return; + + const apiKey = Constants.expoConfig?.extra?.revenueCatAndroidPublicKey + ?? process.env.EXPO_PUBLIC_REVENUECAT_ANDROID_PUBLIC_KEY; + + if (!apiKey) { + console.warn('RevenueCat: missing API key. Set EXPO_PUBLIC_REVENUECAT_ANDROID_PUBLIC_KEY in .env'); + return; + } + + Purchases.configure({ apiKey }); + this.initialized = true; + } + + async getOfferings(): Promise { + if (!this.initialized) return null; + try { + const offerings = await Purchases.getOfferings(); + if (offerings.current) { + console.log('[RevenueCat] Current offering:', offerings.current.identifier); + offerings.current.availablePackages.forEach((pkg) => { + console.log(`[RevenueCat] Package "${pkg.identifier}" — ${pkg.product.priceString} (${pkg.product.identifier})`); + }); + } else { + console.log('[RevenueCat] No current offering available'); + } + return offerings; + } catch (error) { + // SDK already logs the full error; keep our log at debug level + console.debug('RevenueCat offerings unavailable:', error.code); + return null; + } + } + + async purchasePlan(pkg: PurchasesPackage): Promise { + const { customerInfo } = await Purchases.purchasePackage(pkg); + return customerInfo; + } + + async restorePurchases(): Promise { + const { customerInfo } = await Purchases.restorePurchases(); + return customerInfo; + } +} + +export default RevenueCatService; +export type { Offerings, PurchasesPackage, CustomerInfo }; diff --git a/src/services/taskService.ts b/src/services/taskService.ts index e738998..6c3f33b 100644 --- a/src/services/taskService.ts +++ b/src/services/taskService.ts @@ -5,6 +5,15 @@ import TaskCacheService from './TaskCacheService'; import SyncManager from './SyncManager'; import { emitTaskAdded, emitTaskUpdated, emitTaskDeleted, emitTasksSynced } from '../utils/eventEmitter'; +// MyTaskly API Changes — 2026-04-16 +// POST /categories +export class CategoryLimitError extends Error { + constructor() { + super('Category limit reached for your current plan'); + this.name = 'CategoryLimitError'; + } +} + // Lazy initialization dei servizi per evitare problemi di caricamento let cacheService: TaskCacheService | null = null; let syncManager: SyncManager | null = null; @@ -343,11 +352,9 @@ export async function getAllTasks(useCache: boolean = true) { try { // Usa category_id se disponibile, altrimenti fallback su category.name const categoryIdentifier = category.category_id || category.id || category.name; - console.log(`[getAllTasks] Recuperando task per categoria: "${category.name}" (ID: ${categoryIdentifier})`); - const categoryTasks = await getTasks(categoryIdentifier, false); // Non usare cache per singole categorie + const categoryTasks = await getTasks(categoryIdentifier, false); if (Array.isArray(categoryTasks)) { - // Correggi i task che hanno category_name/category_id undefined o mancante const correctedTasks = categoryTasks.map(task => { const needsCategoryIdFix = !task.category_id; const needsCategoryNameFix = !task.category_name || task.category_name === 'undefined'; @@ -717,20 +724,11 @@ export async function addTask(task: Task) { } // Assicurati che le date siano nel formato corretto - const startTime = task.start_time ? new Date(task.start_time) : new Date(); const endTime = task.end_time ? new Date(task.end_time) : null; - // Validazione: end_time deve essere successiva a start_time - if (endTime && endTime <= startTime) { - console.warn("⚠️ ATTENZIONE: end_time è precedente o uguale a start_time"); - console.warn("start_time:", startTime.toISOString()); - console.warn("end_time:", endTime.toISOString()); - } - const data: any = { title: task.title, description: task.description || "", - start_time: startTime.toISOString(), end_time: endTime ? endTime.toISOString() : null, priority: task.priority, status: task.status || "In sospeso", @@ -910,7 +908,10 @@ export async function addCategory(category: { console.log("Risposta dal server:", responseData); return responseData; - } catch (error) { + } catch (error: any) { + if (error.response?.status === 403) { + throw new CategoryLimitError(); + } console.error("Errore in addCategory:", error); throw error; } diff --git a/src/services/voiceBotService.ts b/src/services/voiceBotService.ts index 6b62aa4..d1204c7 100644 --- a/src/services/voiceBotService.ts +++ b/src/services/voiceBotService.ts @@ -122,8 +122,10 @@ export interface VoiceChatCallbacks { onAuthenticationFailed?: (error: string) => void; onReady?: () => void; onDone?: () => void; - /** Called when the server closes the connection with code 4029 (voice quota exceeded). */ + /** Called when the server closes with code 4003 (plan without voice). */ onVoiceQuotaExceeded?: () => void; + /** Called when the server closes with code 4029 (monthly voice quota exhausted). */ + onVoiceMonthlyLimitReached?: () => void; } /** @@ -248,13 +250,20 @@ export class VoiceBotWebSocket { _vLog(`WS chiuso — code=${event.code} reason="${event.reason}" reconnectAttempts=${this.reconnectAttempts}`); this.callbacks.onConnectionClose?.(); - if (event.code === 4029) { - // Voice quota exceeded — do not reconnect - _vLog('WS chiuso con 4029 — quota vocale esaurita, nessun reconnect'); + if (event.code === 4003) { + // Plan without voice — do not reconnect + _vLog('WS chiuso con 4003 — piano senza voce, nessun reconnect'); this.callbacks.onVoiceQuotaExceeded?.(); return; } + if (event.code === 4029) { + // Monthly voice quota exhausted — do not reconnect + _vLog('WS chiuso con 4029 — quota mensile vocale esaurita, nessun reconnect'); + this.callbacks.onVoiceMonthlyLimitReached?.(); + return; + } + if (this.reconnectAttempts < this.maxReconnectAttempts && event.code !== 1000) { this.attemptReconnect(); } @@ -481,10 +490,16 @@ export class VoiceBotWebSocket { private handleErrorResponse(response: VoiceErrorResponse): void { if (!response.message) return; - // Check for voice quota exceeded before auth state handling - if (response.message.toLowerCase().includes('quota exceeded')) { - _vLog('Quota vocale esaurita (error frame)'); - trackVoiceChatError('voice_quota_exceeded'); + const msgLower = response.message.toLowerCase(); + if (msgLower.includes('monthly voice request quota exceeded')) { + _vLog('Quota mensile vocale esaurita (error frame)'); + trackVoiceChatError('voice_monthly_limit_reached'); + this.callbacks.onVoiceMonthlyLimitReached?.(); + return; + } + if (msgLower.includes('not available on your current plan')) { + _vLog('Chat vocale non disponibile per il piano (error frame)'); + trackVoiceChatError('voice_not_available_on_plan'); this.callbacks.onVoiceQuotaExceeded?.(); return; } diff --git a/src/theme/tokens.ts b/src/theme/tokens.ts new file mode 100644 index 0000000..8406b1d --- /dev/null +++ b/src/theme/tokens.ts @@ -0,0 +1,109 @@ +export const spacing = { + xxs: 2, + xs: 4, + sm: 8, + md: 12, + lg: 16, + xl: 20, + xxl: 24, + xxxl: 32, +} as const; + +export const radius = { + sm: 8, + md: 12, + lg: 16, + xl: 20, + pill: 999, +} as const; + +export const elevation = { + none: { + shadowColor: "#000000", + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0, + shadowRadius: 0, + elevation: 0, + }, + sm: { + shadowColor: "#000000", + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.06, + shadowRadius: 2, + elevation: 1, + }, + md: { + shadowColor: "#000000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.08, + shadowRadius: 6, + elevation: 2, + }, + lg: { + shadowColor: "#000000", + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.1, + shadowRadius: 12, + elevation: 4, + }, +} as const; + +export const colors = { + background: "#ffffff", + surface: "#ffffff", + surfaceMuted: "#f8f8f8", + border: "#e1e5e9", + borderSoft: "#f0f0f0", + textPrimary: "#000000", + textSecondary: "#666666", + textTertiary: "#999999", + accent: "#007AFF", + success: "#10b981", + warning: "#f59e0b", + danger: "#ef4444", +} as const; + +export const typography = { + display: { + fontSize: 30, + lineHeight: 36, + fontWeight: "700", + letterSpacing: -1.2, + }, + title: { + fontSize: 24, + lineHeight: 30, + fontWeight: "600", + letterSpacing: -0.8, + }, + subtitle: { + fontSize: 18, + lineHeight: 24, + fontWeight: "500", + letterSpacing: -0.4, + }, + body: { + fontSize: 16, + lineHeight: 22, + fontWeight: "400", + letterSpacing: -0.2, + }, + caption: { + fontSize: 13, + lineHeight: 18, + fontWeight: "400", + letterSpacing: 0, + }, + label: { + fontSize: 12, + lineHeight: 16, + fontWeight: "500", + letterSpacing: 0.1, + }, +} as const; + +export type SpacingToken = keyof typeof spacing; +export type RadiusToken = keyof typeof radius; +export type ElevationToken = keyof typeof elevation; +export type ColorToken = keyof typeof colors; +export type TypographyToken = keyof typeof typography;