From baa197708339de1e922065c3f014f632ee80f457 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Fri, 17 Apr 2026 19:53:30 +0200 Subject: [PATCH 01/37] feat(subscription): handle 403 category limit + fix floating button safe area MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CategoryLimitError thrown on POST /categories 403 — shows upgrade alert instead of generic error. QuickAddButton and AddTaskButton now respect bottom safe area inset so buttons don't overlap home indicator on notched devices. SafeAreaView on Home now excludes bottom edge to avoid double inset with tab bar. --- .claude/settings.local.json | 5 ++++- src/components/Category/AddCategoryButton.tsx | 9 ++++++++- src/components/Task/QuickAddButton.tsx | 3 +++ src/components/TaskList/AddTaskButton.tsx | 4 +++- src/navigation/screens/Home.tsx | 2 +- src/services/taskService.ts | 14 +++++++++++++- 6 files changed, 32 insertions(+), 5 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 01037b4..285c1a1 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -68,7 +68,10 @@ "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" ], "deny": [], "defaultMode": "acceptEdits" 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/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/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/navigation/screens/Home.tsx b/src/navigation/screens/Home.tsx index 3580c64..719b42c 100644 --- a/src/navigation/screens/Home.tsx +++ b/src/navigation/screens/Home.tsx @@ -679,7 +679,7 @@ const HomeScreen = () => { return ( - + {/* Header con titolo principale e indicatori sync */} diff --git a/src/services/taskService.ts b/src/services/taskService.ts index e738998..e7491cd 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; @@ -910,7 +919,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; } From 451108065c0a5223d0e2c1b544d5df3dd8ce9440 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Fri, 17 Apr 2026 19:53:40 +0200 Subject: [PATCH 02/37] feat(subscription): implement Chat Dual Rate Limits + billing/cancel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /auth/me now returns monthly limits for text and voice. UserSubscription gains chat_text_monthly_limit, chat_voice_monthly_limit, chat_voice_daily_limit. WS close code 4029 (monthly voice quota exhausted) no longer triggers reconnect — fires onVoiceMonthlyLimitReached instead. Code 4003 stays for plans without voice. POST /billing/cancel wired via cancelSubscription() in planService. Voice display in AISettings/Settings switches from enabled/disabled boolean to monthly quota number (or Unlimited). Locales: daily instead of monthly for text quota; new keys for voice monthly limit alert. --- src/components/BotChat/VoiceChatModal.tsx | 26 ++++++ src/hooks/useVoiceChat.ts | 11 ++- src/locales/en.json | 15 ++-- src/locales/it.json | 15 ++-- src/navigation/screens/AISettings.tsx | 100 ++++++++++++---------- src/navigation/screens/Settings.tsx | 49 ++--------- src/services/planService.ts | 61 ++++++++++--- src/services/voiceBotService.ts | 31 +++++-- 8 files changed, 191 insertions(+), 117 deletions(-) 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/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..d852c2d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -918,12 +918,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...", diff --git a/src/locales/it.json b/src/locales/it.json index 8d83aea..55226f3 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -918,12 +918,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...", diff --git a/src/navigation/screens/AISettings.tsx b/src/navigation/screens/AISettings.tsx index a86f2cb..7c6baed 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,45 +95,39 @@ 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!')} @@ -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/Settings.tsx b/src/navigation/screens/Settings.tsx index e1e9f5c..c91eab2 100644 --- a/src/navigation/screens/Settings.tsx +++ b/src/navigation/screens/Settings.tsx @@ -53,31 +53,6 @@ export default function Settings() { } }; - 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 ( @@ -96,49 +71,43 @@ export default function Settings() { ); } - const textUnlimited = isUnlimitedPlan(planData.text_messages_limit); - const voiceUnlimited = isUnlimitedPlan(planData.voice_requests_limit); + const textUnlimited = isUnlimitedPlan(planData.chat_text_daily_limit); return ( {/* Plan badge */} - {planData.plan} + {planData.effective_plan.toUpperCase()} - - {t('planUsage.resetsOn', { date: formatResetDate(planData.reset_date) })} - - {/* Text messages */} + {/* Daily text messages */} - {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)} - {/* Voice requests */} + {/* Voice access */} {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)} {/* Upgrade CTA for FREE */} - {planData.plan === 'FREE' && ( + {planData.effective_plan.toLowerCase() === 'free' && ( Alert.alert(t('planUsage.upgrade'), 'Coming soon!')} 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/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; } From be77b49f22fc95f34c113c05323f450e60e6493b Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Fri, 17 Apr 2026 21:55:05 +0200 Subject: [PATCH 03/37] feat(subscription): add SubscriptionPlans screen with RevenueCat integration - Install react-native-purchases for Play Store IAP - Add planLimits.ts with tier limits and product IDs - Create revenueCatService.ts singleton (configure, getOfferings, purchasePlan, restorePurchases) - Add SubscriptionPlans screen with plan cards (Free/Pro/Premium) - Wire upgrade buttons in Settings/AISettings to navigate - Implement purchase, cancel, and restore flows - Add EN/IT localization for subscriptionPlans - Navigation: add SubscriptionPlans to RootStackParamList Note: User must add REVENUECAT_ANDROID_PUBLIC_KEY to .env before build --- .../subscription-plans-screen/.openspec.yaml | 2 + .../subscription-plans-screen/design.md | 55 ++ .../subscription-plans-screen/proposal.md | 45 ++ .../specs/plan-display/spec.md | 16 + .../specs/revenuecat-integration/spec.md | 54 ++ .../specs/subscription-plans-screen/spec.md | 56 ++ .../subscription-plans-screen/tasks.md | 43 ++ package-lock.json | 47 ++ package.json | 1 + src/constants/planLimits.ts | 62 ++ src/locales/en.json | 26 + src/locales/it.json | 26 + src/navigation/index.tsx | 7 + src/navigation/screens/AISettings.tsx | 2 +- src/navigation/screens/Settings.tsx | 2 +- src/navigation/screens/SubscriptionPlans.tsx | 531 ++++++++++++++++++ src/services/revenueCatService.ts | 54 ++ 17 files changed, 1027 insertions(+), 2 deletions(-) create mode 100644 openspec/changes/subscription-plans-screen/.openspec.yaml create mode 100644 openspec/changes/subscription-plans-screen/design.md create mode 100644 openspec/changes/subscription-plans-screen/proposal.md create mode 100644 openspec/changes/subscription-plans-screen/specs/plan-display/spec.md create mode 100644 openspec/changes/subscription-plans-screen/specs/revenuecat-integration/spec.md create mode 100644 openspec/changes/subscription-plans-screen/specs/subscription-plans-screen/spec.md create mode 100644 openspec/changes/subscription-plans-screen/tasks.md create mode 100644 src/constants/planLimits.ts create mode 100644 src/navigation/screens/SubscriptionPlans.tsx create mode 100644 src/services/revenueCatService.ts 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/constants/planLimits.ts b/src/constants/planLimits.ts new file mode 100644 index 0000000..1c388c7 --- /dev/null +++ b/src/constants/planLimits.ts @@ -0,0 +1,62 @@ +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; + productId?: string; +} + +export const PLAN_PRODUCT_IDS: Record<'free' | 'pro' | 'premium', string | undefined> = { + free: undefined, + pro: 'mytaskly_pro_monthly', + premium: 'mytaskly_premium_monthly', +}; + +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, + }, + }, + pro: { + id: 'pro', + name: 'Pro', + productId: PLAN_PRODUCT_IDS.pro, + limits: { + chatTextDaily: 50, + chatTextMonthly: 250, + chatVoiceDaily: Infinity, + chatVoiceMonthly: 50, + aiModel: 'advanced', + maxCategories: Infinity, + }, + }, + premium: { + id: 'premium', + name: 'Premium', + productId: PLAN_PRODUCT_IDS.premium, + limits: { + chatTextDaily: Infinity, + chatTextMonthly: 400, + chatVoiceDaily: Infinity, + chatVoiceMonthly: 150, + aiModel: 'advanced', + maxCategories: Infinity, + }, + }, +}; diff --git a/src/locales/en.json b/src/locales/en.json index d852c2d..044f7dd 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -967,5 +967,31 @@ "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" } } diff --git a/src/locales/it.json b/src/locales/it.json index 55226f3..2b327cc 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -967,5 +967,31 @@ "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" } } diff --git a/src/navigation/index.tsx b/src/navigation/index.tsx index 5ffc33c..5ef83d7 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"; @@ -86,6 +87,7 @@ export type RootStackParamList = { MemorySettings: undefined; AISettings: undefined; RecurringTasks: undefined; + SubscriptionPlans: undefined; }; // Definizione del tipo per le route dei Tab @@ -520,6 +522,11 @@ function AppStack() { component={RecurringTasksScreen} options={{ title: 'Task ricorrenti' }} /> + diff --git a/src/navigation/screens/AISettings.tsx b/src/navigation/screens/AISettings.tsx index 7c6baed..a07f8c1 100644 --- a/src/navigation/screens/AISettings.tsx +++ b/src/navigation/screens/AISettings.tsx @@ -130,7 +130,7 @@ export default function AISettings() { {planData.effective_plan.toLowerCase() === 'free' && ( Alert.alert(t('planUsage.upgrade'), 'Coming soon!')} + onPress={() => navigation.navigate('SubscriptionPlans')} > {t('planUsage.upgrade')} diff --git a/src/navigation/screens/Settings.tsx b/src/navigation/screens/Settings.tsx index c91eab2..8ddab9e 100644 --- a/src/navigation/screens/Settings.tsx +++ b/src/navigation/screens/Settings.tsx @@ -110,7 +110,7 @@ export default function Settings() { {planData.effective_plan.toLowerCase() === 'free' && ( Alert.alert(t('planUsage.upgrade'), 'Coming soon!')} + onPress={() => navigation.navigate('SubscriptionPlans')} > {t('planUsage.upgrade')} diff --git a/src/navigation/screens/SubscriptionPlans.tsx b/src/navigation/screens/SubscriptionPlans.tsx new file mode 100644 index 0000000..c680505 --- /dev/null +++ b/src/navigation/screens/SubscriptionPlans.tsx @@ -0,0 +1,531 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + StyleSheet, + View, + Text, + TouchableOpacity, + ScrollView, + SafeAreaView, + Alert, + ActivityIndicator, +} from 'react-native'; +import { StatusBar } from 'expo-status-bar'; +import { useNavigation, NavigationProp } from '@react-navigation/native'; +import { RootStackParamList } from '../../types'; +import { Ionicons } from '@expo/vector-icons'; +import { useTranslation } from 'react-i18next'; +import { + getUserPlan, + cancelSubscription, + UserSubscription, + isUnlimitedPlan, +} from '../../services/planService'; +import RevenueCatService, { + type Offerings, + type PurchasesPackage, + type CustomerInfo, +} from '../../services/revenueCatService'; +import { PLANS, PLAN_PRODUCT_IDS, Plan } from '../../constants/planLimits'; + +export default function SubscriptionPlans() { + const navigation = useNavigation>(); + const { t } = useTranslation(); + + const [planData, setPlanData] = useState(null); + const [offerings, setOfferings] = useState(null); + const [loading, setLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(false); + + const loadData = useCallback(async () => { + try { + setLoading(true); + const [userPlan, rcOfferings] = await Promise.all([ + getUserPlan(), + RevenueCatService.getInstance().getOfferings(), + ]); + setPlanData(userPlan); + setOfferings(rcOfferings); + } catch (error) { + console.error('Failed to load subscription data:', error); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadData(); + }, [loadData]); + + const handlePurchase = useCallback( + async (plan: Plan) => { + if (!plan.productId || !offerings) return; + + try { + setActionLoading(true); + + const packageToPurchase = offerings.current + .availablePackages[0] + .packages.find((pkg) => pkg.identifier === plan.productId); + + if (!packageToPurchase) { + Alert.alert(t('subscriptionPlans.unavailable')); + return; + } + + await RevenueCatService.getInstance().purchasePlan(packageToPurchase); + await loadData(); + Alert.alert(t('subscriptionPlans.purchaseSuccess')); + } catch (error: any) { + if (error?.userCancelled) { + return; + } + Alert.alert( + t('subscriptionPlans.purchaseError'), + error?.message || t('common.error') + ); + } finally { + setActionLoading(false); + } + }, + [offerings, t, loadData] + ); + + const handleCancel = useCallback(async () => { + Alert.alert( + t('subscriptionPlans.cancelConfirm'), + t('subscriptionPlans.cancelConfirmMessage'), + [ + { + text: t('common.cancel'), + style: 'cancel', + }, + { + text: t('common.confirm'), + style: 'destructive', + onPress: async () => { + try { + setActionLoading(true); + await cancelSubscription(); + await loadData(); + Alert.alert(t('subscriptionPlans.cancelSuccess')); + } catch (error: any) { + Alert.alert( + t('subscriptionPlans.cancelError'), + error?.message || t('common.error') + ); + } finally { + setActionLoading(false); + } + }, + }, + ] + ); + }, [t, loadData]); + + const handleRestore = useCallback(async () => { + try { + setActionLoading(true); + await RevenueCatService.getInstance().restorePurchases(); + await loadData(); + Alert.alert(t('subscriptionPlans.restoreSuccess')); + } catch (error: any) { + Alert.alert( + t('subscriptionPlans.restoreError'), + error?.message || t('common.error') + ); + } finally { + setActionLoading(false); + } + }, [t, loadData]); + + const getPackagePrice = useCallback( + (plan: Plan): string => { + if (!offerings || !plan.productId) return '—'; + + const pkg = offerings.current.availablePackages[0].packages.find( + (p) => p.identifier === plan.productId + ); + return pkg?.product.priceString || '—'; + }, + [offerings] + ); + + const isCurrentPlan = useCallback( + (planId: string): boolean => { + return planData?.effective_plan === planId; + }, + [planData] + ); + + const renderPlanCard = (plan: Plan) => { + const isCurrent = isCurrentPlan(plan.id); + const price = getPackagePrice(plan); + const unavailable = !offerings && plan.productId; + const isFree = plan.id === 'free'; + + return ( + + + + {plan.name} + + {isCurrent && ( + + + {t('subscriptionPlans.currentPlan')} + + + )} + + + {price} + + + + + + + + + + + handlePurchase(plan)} + disabled={isCurrent || unavailable || actionLoading} + > + {actionLoading ? ( + + ) : ( + + {isCurrent + ? t('subscriptionPlans.currentPlan') + : unavailable + ? t('subscriptionPlans.unavailable') + : isFree + ? t('subscriptionPlans.currentPlan') + : t('subscriptionPlans.upgradeTo', { plan: plan.name })} + + )} + + + ); + }; + + const renderCancelSection = () => { + if (!planData) return null; + const canCancel = planData.status === 'active'; + + return ( + + {canCancel ? ( + + {actionLoading ? ( + + ) : ( + + {t('subscriptionPlans.cancelSubscription')} + + )} + + ) : planData.status === 'cancelled' && planData.current_period_end ? ( + + + + {t('subscriptionPlans.gracePeriod', { + date: new Date(planData.current_period_end).toLocaleDateString(), + })} + + + ) : null} + + + {actionLoading ? ( + + ) : ( + + {t('subscriptionPlans.restorePurchases')} + + )} + + + ); + }; + + if (loading) { + return ( + + + + + + + ); + } + + return ( + + + + navigation.goBack()} + > + + + + {t('navigation.screens.subscriptionPlans')} + + + + + + {t('subscriptionPlans.description')} + + + {Object.values(PLANS).map(renderPlanCard)} + + {renderCancelSection()} + + + ); +} + +function FeatureRow({ label, value, unlimited }: { + label: string; + value: string | number; + unlimited?: boolean; +}) { + return ( + + {label} + + {unlimited ? '∞' : value} + + + ); +} + +function formatLimit(limit: number | string): string | number { + if (typeof limit === 'string') return limit; + if (limit === Infinity) return '∞'; + return limit; +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: '#ffffff', + }, + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + header: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: '#e1e5e9', + }, + backButton: { + padding: 8, + }, + headerTitle: { + flex: 1, + fontSize: 18, + fontWeight: '600', + color: '#000000', + }, + scrollView: { + flex: 1, + }, + scrollContent: { + padding: 16, + }, + description: { + fontSize: 14, + color: '#666666', + marginBottom: 24, + textAlign: 'center', + }, + planCard: { + backgroundColor: '#ffffff', + borderRadius: 16, + borderWidth: 2, + borderColor: '#e1e5e9', + padding: 20, + marginBottom: 16, + shadowColor: '#000000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.04, + shadowRadius: 8, + elevation: 4, + }, + currentPlanCard: { + borderColor: '#007AFF', + backgroundColor: '#f0f8ff', + }, + freePlanCard: { + borderColor: '#e1e5e9', + }, + planHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 8, + }, + planName: { + fontSize: 20, + fontWeight: '700', + color: '#000000', + }, + currentPlanName: { + color: '#007AFF', + }, + currentBadge: { + backgroundColor: '#007AFF', + paddingHorizontal: 12, + paddingVertical: 4, + borderRadius: 12, + }, + currentBadgeText: { + color: '#ffffff', + fontSize: 12, + fontWeight: '600', + }, + planPrice: { + fontSize: 24, + fontWeight: '600', + color: '#000000', + marginBottom: 20, + }, + features: { + marginBottom: 20, + }, + featureRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: 8, + borderBottomWidth: 1, + borderBottomColor: '#f0f0f0', + }, + featureLabel: { + fontSize: 14, + color: '#666666', + flex: 1, + }, + featureValue: { + fontSize: 14, + fontWeight: '500', + color: '#000000', + }, + upgradeButton: { + backgroundColor: '#000000', + borderRadius: 12, + paddingVertical: 14, + alignItems: 'center', + shadowColor: '#000000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.08, + shadowRadius: 8, + elevation: 4, + }, + disabledButton: { + backgroundColor: '#e1e5e9', + shadowOpacity: 0, + }, + upgradeButtonText: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', + }, + actionsSection: { + marginTop: 24, + }, + actionButton: { + borderRadius: 12, + paddingVertical: 14, + alignItems: 'center', + marginBottom: 12, + }, + cancelButton: { + backgroundColor: '#ff3b30', + }, + cancelButtonText: { + color: '#ffffff', + fontSize: 16, + fontWeight: '600', + }, + restoreButton: { + backgroundColor: '#f0f0f0', + borderWidth: 1, + borderColor: '#e1e5e9', + }, + restoreButtonText: { + color: '#007AFF', + fontSize: 16, + fontWeight: '600', + }, + gracePeriodInfo: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#fff3cd', + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 8, + marginBottom: 12, + }, + gracePeriodText: { + fontSize: 14, + color: '#666666', + marginLeft: 8, + flex: 1, + }, +}); diff --git a/src/services/revenueCatService.ts b/src/services/revenueCatService.ts new file mode 100644 index 0000000..83bbec6 --- /dev/null +++ b/src/services/revenueCatService.ts @@ -0,0 +1,54 @@ +import Purchases from 'react-native-purchases'; +import { Platform } from 'react-native'; + +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(); + } + return RevenueCatService.instance; + } + + private configure(apiKey: string): void { + if (this.initialized) return; + + Purchases.configure({ apiKey }); + this.initialized = true; + } + + async getOfferings(): Promise { + try { + const offerings = await Purchases.getOfferings(); + return offerings; + } catch (error) { + console.warn('Failed to fetch RevenueCat offerings:', error); + return null; + } + } + + async purchasePlan(pkg: PurchasesPackage): Promise { + try { + const { customerInfo } = await Purchases.purchasePackage(pkg); + return customerInfo; + } catch (error) { + throw error; + } + } + + async restorePurchases(): Promise { + const { customerInfo } = await Purchases.restorePurchases(); + return customerInfo; + } +} + +export default RevenueCatService; +export type { Offerings, PurchasesPackage, CustomerInfo }; From 8a4ff95dca7e77642c2ae10669409ff0152507bc Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Mon, 27 Apr 2026 16:38:15 +0200 Subject: [PATCH 04/37] feat(subscriptions): implement real plan UI and offline fallback - Remove test buttons and render local plans properly - Map real prices from RevenueCat when available - Show offline warning and placeholder price ('--') when RevenueCat fetch fails - Improve UI with modern layout, shadows, colors, and feature checkmarks --- .claude/settings.local.json | 3 +- src/navigation/screens/Settings.tsx | 42 +- src/navigation/screens/SubscriptionPlans.tsx | 385 +++++++++++-------- src/services/revenueCatService.ts | 22 +- 4 files changed, 289 insertions(+), 163 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 285c1a1..0526634 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -71,7 +71,8 @@ "Bash(but status:*)", "mcp__claude_ai_Notion__notion-fetch", "mcp__claude_ai_Notion__notion-search", - "mcp__claude_ai_Notion__notion-update-page" + "mcp__claude_ai_Notion__notion-update-page", + "Bash(openspec list *)" ], "deny": [], "defaultMode": "acceptEdits" diff --git a/src/navigation/screens/Settings.tsx b/src/navigation/screens/Settings.tsx index 8ddab9e..dc8e333 100644 --- a/src/navigation/screens/Settings.tsx +++ b/src/navigation/screens/Settings.tsx @@ -11,6 +11,9 @@ import { useTutorialContext } from '../../contexts/TutorialContext'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { TUTORIAL_STORAGE_KEY } from '../../constants/tutorialContent'; import { getUserPlan, isUnlimitedPlan, UserPlan } from '../../services/planService'; +import { STORAGE_KEYS } from '../../constants/authConstants'; +import axiosInstance from '../../services/axiosInstance'; +import { getValidToken } from '../../services/authService'; export default function Settings() { const navigation = useNavigation>(); @@ -53,6 +56,29 @@ export default function Settings() { } }; + const handleTestNotification = async () => { + try { + 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 renderPlanSection = () => { if (planLoading) { return ( @@ -120,9 +146,9 @@ export default function Settings() { }; return ( - + - + {/* Account Section */} @@ -229,6 +255,17 @@ export default function Settings() { + + + + Test notifica + + + + >(); const { t } = useTranslation(); const [planData, setPlanData] = useState(null); @@ -58,30 +54,38 @@ export default function SubscriptionPlans() { const handlePurchase = useCallback( async (plan: Plan) => { - if (!plan.productId || !offerings) return; + if (plan.id === 'free') return; // Cannot buy free plan + + if (!plan.productId || !offerings) { + Alert.alert( + t('common.error', 'Errore'), + t('subscriptionPlans.offlineError', 'Servizio offline o piano non disponibile.') + ); + return; + } try { setActionLoading(true); - const packageToPurchase = offerings.current - .availablePackages[0] - .packages.find((pkg) => pkg.identifier === plan.productId); + const packageToPurchase = offerings.current?.availablePackages.find( + (pkg) => pkg.identifier === plan.productId + ); if (!packageToPurchase) { - Alert.alert(t('subscriptionPlans.unavailable')); + Alert.alert(t('subscriptionPlans.unavailable', 'Non disponibile al momento.')); return; } await RevenueCatService.getInstance().purchasePlan(packageToPurchase); await loadData(); - Alert.alert(t('subscriptionPlans.purchaseSuccess')); + Alert.alert(t('subscriptionPlans.purchaseSuccess', 'Acquisto completato con successo!')); } catch (error: any) { if (error?.userCancelled) { return; } Alert.alert( - t('subscriptionPlans.purchaseError'), - error?.message || t('common.error') + t('subscriptionPlans.purchaseError', 'Errore durante l\'acquisto'), + error?.message || t('common.error', 'Si è verificato un errore sconosciuto.') ); } finally { setActionLoading(false); @@ -92,26 +96,26 @@ export default function SubscriptionPlans() { const handleCancel = useCallback(async () => { Alert.alert( - t('subscriptionPlans.cancelConfirm'), - t('subscriptionPlans.cancelConfirmMessage'), + t('subscriptionPlans.cancelConfirm', 'Annullare abbonamento?'), + t('subscriptionPlans.cancelConfirmMessage', 'Sei sicuro di voler annullare? Manterrai i benefici fino alla scadenza del periodo attuale.'), [ { - text: t('common.cancel'), + text: t('common.cancel', 'Annulla'), style: 'cancel', }, { - text: t('common.confirm'), + text: t('common.confirm', 'Conferma'), style: 'destructive', onPress: async () => { try { setActionLoading(true); await cancelSubscription(); await loadData(); - Alert.alert(t('subscriptionPlans.cancelSuccess')); + Alert.alert(t('subscriptionPlans.cancelSuccess', 'Abbonamento annullato.')); } catch (error: any) { Alert.alert( - t('subscriptionPlans.cancelError'), - error?.message || t('common.error') + t('subscriptionPlans.cancelError', 'Errore annullamento'), + error?.message || t('common.error', 'Errore imprevisto.') ); } finally { setActionLoading(false); @@ -127,29 +131,17 @@ export default function SubscriptionPlans() { setActionLoading(true); await RevenueCatService.getInstance().restorePurchases(); await loadData(); - Alert.alert(t('subscriptionPlans.restoreSuccess')); + Alert.alert(t('subscriptionPlans.restoreSuccess', 'Acquisti ripristinati correttamente.')); } catch (error: any) { Alert.alert( - t('subscriptionPlans.restoreError'), - error?.message || t('common.error') + t('subscriptionPlans.restoreError', 'Errore ripristino'), + error?.message || t('common.error', 'Si è verificato un errore.') ); } finally { setActionLoading(false); } }, [t, loadData]); - const getPackagePrice = useCallback( - (plan: Plan): string => { - if (!offerings || !plan.productId) return '—'; - - const pkg = offerings.current.availablePackages[0].packages.find( - (p) => p.identifier === plan.productId - ); - return pkg?.product.priceString || '—'; - }, - [offerings] - ); - const isCurrentPlan = useCallback( (planId: string): boolean => { return planData?.effective_plan === planId; @@ -159,9 +151,27 @@ export default function SubscriptionPlans() { const renderPlanCard = (plan: Plan) => { const isCurrent = isCurrentPlan(plan.id); - const price = getPackagePrice(plan); - const unavailable = !offerings && plan.productId; const isFree = plan.id === 'free'; + + // Attempt to match local plan with RevenueCat package + const pkg: PurchasesPackage | undefined = offerings?.current?.availablePackages.find( + (p) => p.identifier === plan.productId + ); + + // Determine price and description + let price = isFree ? t('common.free', 'Gratis') : '—'; + let description = t('subscriptionPlans.freeDescription', 'Piano di base.'); + + if (!isFree) { + if (pkg) { + price = pkg.product.priceString; + description = pkg.product.description; + } else { + description = t('subscriptionPlans.offlineDescription', 'Dettagli non disponibili al momento.'); + } + } + + const offlineOrMissing = !isFree && !pkg; return ( - - {plan.name} - + + {!isFree && } + + {pkg?.product.title || plan.name} + + {isCurrent && ( - {t('subscriptionPlans.currentPlan')} + {t('subscriptionPlans.currentPlan', 'Attuale')} )} - {price} + + {price} + {!isFree && !offlineOrMissing && / {t('common.month', 'mese')}} + + {description} @@ -222,21 +240,26 @@ export default function SubscriptionPlans() { handlePurchase(plan)} - disabled={isCurrent || unavailable || actionLoading} + disabled={isCurrent || actionLoading || offlineOrMissing || isFree} > {actionLoading ? ( ) : ( - + {isCurrent - ? t('subscriptionPlans.currentPlan') - : unavailable - ? t('subscriptionPlans.unavailable') + ? t('subscriptionPlans.currentPlan', 'Piano Attuale') + : offlineOrMissing + ? t('subscriptionPlans.unavailable', 'Non disponibile') : isFree - ? t('subscriptionPlans.currentPlan') + ? t('subscriptionPlans.currentPlan', 'Piano Attuale') : t('subscriptionPlans.upgradeTo', { plan: plan.name })} )} @@ -247,7 +270,7 @@ export default function SubscriptionPlans() { const renderCancelSection = () => { if (!planData) return null; - const canCancel = planData.status === 'active'; + const canCancel = planData.status === 'active' && planData.effective_plan !== 'free'; return ( @@ -260,16 +283,19 @@ export default function SubscriptionPlans() { {actionLoading ? ( ) : ( - - {t('subscriptionPlans.cancelSubscription')} - + + + + {t('subscriptionPlans.cancelSubscription', 'Annulla Abbonamento')} + + )} ) : planData.status === 'cancelled' && planData.current_period_end ? ( - + - {t('subscriptionPlans.gracePeriod', { + {t('subscriptionPlans.gracePeriod', 'I tuoi vantaggi scadranno il {{date}}', { date: new Date(planData.current_period_end).toLocaleDateString(), })} @@ -284,9 +310,12 @@ export default function SubscriptionPlans() { {actionLoading ? ( ) : ( - - {t('subscriptionPlans.restorePurchases')} - + + + + {t('subscriptionPlans.restorePurchases', 'Ripristina Acquisti')} + + )} @@ -298,7 +327,7 @@ export default function SubscriptionPlans() { - + ); @@ -307,26 +336,24 @@ export default function SubscriptionPlans() { return ( - - navigation.goBack()} - > - - - - {t('navigation.screens.subscriptionPlans')} - - - {t('subscriptionPlans.description')} + {t('subscriptionPlans.description', 'Scegli il piano migliore per le tue esigenze e sblocca il massimo del potenziale.')} + {!loading && !offerings && ( + + + + {t('subscriptionPlans.offlineWarning', 'Connessione agli store fallita. Alcuni prezzi potrebbero non essere disponibili.')} + + + )} + {Object.values(PLANS).map(renderPlanCard)} {renderCancelSection()} @@ -335,15 +362,19 @@ export default function SubscriptionPlans() { ); } -function FeatureRow({ label, value, unlimited }: { +function FeatureRow({ label, value, unlimited, highlight }: { label: string; value: string | number; unlimited?: boolean; + highlight?: boolean; }) { return ( - {label} - + + + {label} + + {unlimited ? '∞' : value} @@ -359,72 +390,78 @@ function formatLimit(limit: number | string): string | number { const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: '#ffffff', + backgroundColor: '#f8f9fa', }, loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', }, - header: { - flexDirection: 'row', - alignItems: 'center', - paddingHorizontal: 16, - paddingVertical: 12, - borderBottomWidth: 1, - borderBottomColor: '#e1e5e9', - }, - backButton: { - padding: 8, - }, - headerTitle: { - flex: 1, - fontSize: 18, - fontWeight: '600', - color: '#000000', - }, scrollView: { flex: 1, }, scrollContent: { padding: 16, + paddingBottom: 40, }, description: { - fontSize: 14, + fontSize: 16, color: '#666666', marginBottom: 24, textAlign: 'center', + lineHeight: 22, + }, + warningBanner: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#fff3cd', + borderColor: '#ffeeba', + borderWidth: 1, + padding: 12, + borderRadius: 12, + marginBottom: 20, + }, + warningText: { + color: '#856404', + fontSize: 14, + marginLeft: 8, + flex: 1, }, planCard: { backgroundColor: '#ffffff', - borderRadius: 16, + borderRadius: 24, borderWidth: 2, - borderColor: '#e1e5e9', - padding: 20, - marginBottom: 16, + borderColor: 'transparent', + padding: 24, + marginBottom: 20, shadowColor: '#000000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.04, - shadowRadius: 8, - elevation: 4, + shadowOffset: { width: 0, height: 8 }, + shadowOpacity: 0.06, + shadowRadius: 16, + elevation: 6, }, currentPlanCard: { borderColor: '#007AFF', - backgroundColor: '#f0f8ff', + backgroundColor: '#f8fcff', }, freePlanCard: { borderColor: '#e1e5e9', + shadowOpacity: 0.02, }, planHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - marginBottom: 8, + marginBottom: 12, + }, + planTitleContainer: { + flexDirection: 'row', + alignItems: 'center', }, planName: { - fontSize: 20, - fontWeight: '700', - color: '#000000', + fontSize: 22, + fontWeight: '800', + color: '#1c1c1e', }, currentPlanName: { color: '#007AFF', @@ -432,100 +469,144 @@ const styles = StyleSheet.create({ currentBadge: { backgroundColor: '#007AFF', paddingHorizontal: 12, - paddingVertical: 4, - borderRadius: 12, + paddingVertical: 6, + borderRadius: 16, }, currentBadgeText: { color: '#ffffff', fontSize: 12, - fontWeight: '600', + fontWeight: '700', + textTransform: 'uppercase', }, planPrice: { - fontSize: 24, + fontSize: 32, + fontWeight: '800', + color: '#1c1c1e', + marginBottom: 8, + }, + planDuration: { + fontSize: 16, fontWeight: '600', - color: '#000000', - marginBottom: 20, + color: '#8e8e93', + }, + planDescription: { + fontSize: 15, + color: '#8e8e93', + marginBottom: 24, + lineHeight: 20, }, features: { - marginBottom: 20, + marginBottom: 28, }, featureRow: { flexDirection: 'row', justifyContent: 'space-between', - paddingVertical: 8, + alignItems: 'center', + paddingVertical: 12, borderBottomWidth: 1, - borderBottomColor: '#f0f0f0', + borderBottomColor: '#f2f2f7', }, - featureLabel: { - fontSize: 14, - color: '#666666', + featureLabelContainer: { + flexDirection: 'row', + alignItems: 'center', flex: 1, }, - featureValue: { - fontSize: 14, + featureLabel: { + fontSize: 15, + color: '#3a3a3c', fontWeight: '500', - color: '#000000', + }, + featureValue: { + fontSize: 16, + fontWeight: '700', + color: '#1c1c1e', + }, + featureValueHighlight: { + color: '#007AFF', }, upgradeButton: { - backgroundColor: '#000000', - borderRadius: 12, - paddingVertical: 14, + backgroundColor: '#007AFF', + borderRadius: 16, + paddingVertical: 16, alignItems: 'center', - shadowColor: '#000000', - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.08, + shadowColor: '#007AFF', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, shadowRadius: 8, elevation: 4, }, + currentButton: { + backgroundColor: '#f2f2f7', + shadowOpacity: 0, + elevation: 0, + }, disabledButton: { - backgroundColor: '#e1e5e9', + backgroundColor: '#e5e5ea', shadowOpacity: 0, + elevation: 0, }, upgradeButtonText: { color: '#ffffff', - fontSize: 16, - fontWeight: '600', + fontSize: 17, + fontWeight: '700', + }, + currentButtonText: { + color: '#8e8e93', + }, + disabledButtonText: { + color: '#8e8e93', }, actionsSection: { - marginTop: 24, + marginTop: 16, }, actionButton: { - borderRadius: 12, - paddingVertical: 14, - alignItems: 'center', + borderRadius: 16, + paddingVertical: 16, marginBottom: 12, }, + actionButtonContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, cancelButton: { backgroundColor: '#ff3b30', + shadowColor: '#ff3b30', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.2, + shadowRadius: 8, + elevation: 4, }, cancelButtonText: { color: '#ffffff', - fontSize: 16, - fontWeight: '600', + fontSize: 17, + fontWeight: '700', }, restoreButton: { - backgroundColor: '#f0f0f0', + backgroundColor: '#ffffff', borderWidth: 1, - borderColor: '#e1e5e9', + borderColor: '#d1d1d6', }, restoreButtonText: { color: '#007AFF', - fontSize: 16, - fontWeight: '600', + fontSize: 17, + fontWeight: '700', }, gracePeriodInfo: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#fff3cd', - paddingHorizontal: 12, - paddingVertical: 8, - borderRadius: 8, - marginBottom: 12, + paddingHorizontal: 16, + paddingVertical: 12, + borderRadius: 12, + marginBottom: 16, }, gracePeriodText: { - fontSize: 14, - color: '#666666', + fontSize: 15, + color: '#856404', marginLeft: 8, flex: 1, + fontWeight: '500', + lineHeight: 20, }, }); diff --git a/src/services/revenueCatService.ts b/src/services/revenueCatService.ts index 83bbec6..1b028df 100644 --- a/src/services/revenueCatService.ts +++ b/src/services/revenueCatService.ts @@ -1,5 +1,6 @@ import Purchases from 'react-native-purchases'; import { Platform } from 'react-native'; +import Constants from 'expo-constants'; type Offerings = Purchases.Offerings; type PurchasesPackage = Purchases.Package; @@ -14,18 +15,29 @@ class RevenueCatService { static getInstance(): RevenueCatService { if (!RevenueCatService.instance) { RevenueCatService.instance = new RevenueCatService(); + RevenueCatService.instance.init(); } return RevenueCatService.instance; } - private configure(apiKey: string): void { + 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(); return offerings; @@ -36,12 +48,8 @@ class RevenueCatService { } async purchasePlan(pkg: PurchasesPackage): Promise { - try { - const { customerInfo } = await Purchases.purchasePackage(pkg); - return customerInfo; - } catch (error) { - throw error; - } + const { customerInfo } = await Purchases.purchasePackage(pkg); + return customerInfo; } async restorePurchases(): Promise { From e91912cc35100886c17099d6f508d4f993971674 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Mon, 27 Apr 2026 16:49:24 +0200 Subject: [PATCH 05/37] feat(subscriptions): implement Revolut-style UI for plans --- src/navigation/screens/SubscriptionPlans.tsx | 684 ++++++++----------- 1 file changed, 283 insertions(+), 401 deletions(-) diff --git a/src/navigation/screens/SubscriptionPlans.tsx b/src/navigation/screens/SubscriptionPlans.tsx index f78559a..95d3a06 100644 --- a/src/navigation/screens/SubscriptionPlans.tsx +++ b/src/navigation/screens/SubscriptionPlans.tsx @@ -8,13 +8,13 @@ import { 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, - cancelSubscription, UserSubscription, isUnlimitedPlan, } from '../../services/planService'; @@ -32,6 +32,9 @@ export default function SubscriptionPlans() { const [loading, setLoading] = useState(true); const [actionLoading, setActionLoading] = useState(false); + // New state for the Revolut-style tab selector + const [selectedPlanId, setSelectedPlanId] = useState('free'); + const loadData = useCallback(async () => { try { setLoading(true); @@ -41,6 +44,11 @@ export default function SubscriptionPlans() { ]); setPlanData(userPlan); setOfferings(rcOfferings); + + // Default selected plan to current active plan, or free + if (userPlan?.effective_plan) { + setSelectedPlanId(userPlan.effective_plan); + } } catch (error) { console.error('Failed to load subscription data:', error); } finally { @@ -54,7 +62,7 @@ export default function SubscriptionPlans() { const handlePurchase = useCallback( async (plan: Plan) => { - if (plan.id === 'free') return; // Cannot buy free plan + if (plan.id === 'free') return; if (!plan.productId || !offerings) { Alert.alert( @@ -94,54 +102,6 @@ export default function SubscriptionPlans() { [offerings, t, loadData] ); - const handleCancel = useCallback(async () => { - Alert.alert( - t('subscriptionPlans.cancelConfirm', 'Annullare abbonamento?'), - t('subscriptionPlans.cancelConfirmMessage', 'Sei sicuro di voler annullare? Manterrai i benefici fino alla scadenza del periodo attuale.'), - [ - { - text: t('common.cancel', 'Annulla'), - style: 'cancel', - }, - { - text: t('common.confirm', 'Conferma'), - style: 'destructive', - onPress: async () => { - try { - setActionLoading(true); - await cancelSubscription(); - await loadData(); - Alert.alert(t('subscriptionPlans.cancelSuccess', 'Abbonamento annullato.')); - } catch (error: any) { - Alert.alert( - t('subscriptionPlans.cancelError', 'Errore annullamento'), - error?.message || t('common.error', 'Errore imprevisto.') - ); - } finally { - setActionLoading(false); - } - }, - }, - ] - ); - }, [t, loadData]); - - const handleRestore = useCallback(async () => { - try { - setActionLoading(true); - await RevenueCatService.getInstance().restorePurchases(); - await loadData(); - Alert.alert(t('subscriptionPlans.restoreSuccess', 'Acquisti ripristinati correttamente.')); - } catch (error: any) { - Alert.alert( - t('subscriptionPlans.restoreError', 'Errore ripristino'), - error?.message || t('common.error', 'Si è verificato un errore.') - ); - } finally { - setActionLoading(false); - } - }, [t, loadData]); - const isCurrentPlan = useCallback( (planId: string): boolean => { return planData?.effective_plan === planId; @@ -149,248 +109,194 @@ export default function SubscriptionPlans() { [planData] ); - const renderPlanCard = (plan: Plan) => { - const isCurrent = isCurrentPlan(plan.id); - const isFree = plan.id === 'free'; - - // Attempt to match local plan with RevenueCat package - const pkg: PurchasesPackage | undefined = offerings?.current?.availablePackages.find( - (p) => p.identifier === plan.productId + // Helper to format limit for the new UI feature list + const formatFeatureLimit = (daily: number | string, monthly: number | string) => { + const d = isUnlimitedPlan(daily) ? '∞' : daily; + const m = isUnlimitedPlan(monthly) ? '∞' : monthly; + return `${d} ${t('common.daily', 'giornalieri')} • ${m} ${t('common.monthly', 'mensili')}`; + }; + + if (loading) { + return ( + + + + + + ); + } - // Determine price and description - let price = isFree ? t('common.free', 'Gratis') : '—'; - let description = t('subscriptionPlans.freeDescription', 'Piano di base.'); - - if (!isFree) { - if (pkg) { - price = pkg.product.priceString; - description = pkg.product.description; - } else { - description = t('subscriptionPlans.offlineDescription', 'Dettagli non disponibili al momento.'); - } + 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: PurchasesPackage | undefined = offerings?.current?.availablePackages.find( + (p) => p.identifier === selectedPlan.productId + ); + + let priceStr = isFreeSelected ? t('common.free', 'Complimentary') : '—'; + let subtitleStr = t('subscriptionPlans.freeDescription', 'Just the basics'); + + if (!isFreeSelected) { + if (pkg) { + priceStr = pkg.product.priceString; + subtitleStr = pkg.product.description || t('subscriptionPlans.premiumDesc', 'Unlock all premium features'); + } else { + subtitleStr = t('subscriptionPlans.offlineDescription', 'Dettagli non disponibili al momento.'); } + } - const offlineOrMissing = !isFree && !pkg; + const offlineOrMissing = !isFreeSelected && !pkg; - return ( - - - - {!isFree && } - - {pkg?.product.title || plan.name} + return ( + + + + + + {/* Title */} + {t('subscriptionPlans.selectPlan', 'Select plan')} + + {/* Warning Banner */} + {!loading && !offerings && ( + + + + {t('subscriptionPlans.offlineWarning', 'Offline. Prezzi non disponibili.')} - {isCurrent && ( - - - {t('subscriptionPlans.currentPlan', 'Attuale')} - - - )} + )} + + {/* Plan Tabs */} + + {plansArray.map((p) => { + const isActive = p.id === selectedPlanId; + return ( + setSelectedPlanId(p.id)} + > + + {p.name} + + + ); + })} - - {price} - {!isFree && !offlineOrMissing && / {t('common.month', 'mese')}} - - {description} - - - - - + + {pkg?.product.title || selectedPlan.name} + {isSelectedPlanCurrent && ( + + + {t('subscriptionPlans.active', 'Active')} + + )} + + + {priceStr} + {subtitleStr} + + + {/* Features Section */} + {t('subscriptionPlans.topFeatures', 'Top features')} + + + + - - - + + + + + {/* Bottom Action Button Fixed */} + handlePurchase(plan)} - disabled={isCurrent || actionLoading || offlineOrMissing || isFree} + onPress={() => handlePurchase(selectedPlan)} + disabled={isSelectedPlanCurrent || actionLoading || offlineOrMissing || isFreeSelected} > {actionLoading ? ( ) : ( - {isCurrent - ? t('subscriptionPlans.currentPlan', 'Piano Attuale') + {isSelectedPlanCurrent + ? t('subscriptionPlans.currentPlan', 'Current Plan') : offlineOrMissing - ? t('subscriptionPlans.unavailable', 'Non disponibile') - : isFree - ? t('subscriptionPlans.currentPlan', 'Piano Attuale') - : t('subscriptionPlans.upgradeTo', { plan: plan.name })} + ? t('subscriptionPlans.unavailable', 'Unavailable') + : isFreeSelected + ? t('subscriptionPlans.currentPlan', 'Current Plan') + : t('subscriptionPlans.getPlan', 'Get {{plan}}', { plan: selectedPlan.name })} )} - ); - }; - - const renderCancelSection = () => { - if (!planData) return null; - const canCancel = planData.status === 'active' && planData.effective_plan !== 'free'; - - return ( - - {canCancel ? ( - - {actionLoading ? ( - - ) : ( - - - - {t('subscriptionPlans.cancelSubscription', 'Annulla Abbonamento')} - - - )} - - ) : planData.status === 'cancelled' && planData.current_period_end ? ( - - - - {t('subscriptionPlans.gracePeriod', 'I tuoi vantaggi scadranno il {{date}}', { - date: new Date(planData.current_period_end).toLocaleDateString(), - })} - - - ) : null} - - - {actionLoading ? ( - - ) : ( - - - - {t('subscriptionPlans.restorePurchases', 'Ripristina Acquisti')} - - - )} - - - ); - }; - if (loading) { - return ( - - - - - - - ); - } - - return ( - - - - - - {t('subscriptionPlans.description', 'Scegli il piano migliore per le tue esigenze e sblocca il massimo del potenziale.')} - - - {!loading && !offerings && ( - - - - {t('subscriptionPlans.offlineWarning', 'Connessione agli store fallita. Alcuni prezzi potrebbero non essere disponibili.')} - - - )} - - {Object.values(PLANS).map(renderPlanCard)} - - {renderCancelSection()} - ); } -function FeatureRow({ label, value, unlimited, highlight }: { - label: string; - value: string | number; - unlimited?: boolean; - highlight?: boolean; -}) { +function FeatureItem({ icon, title, description, isLast }: { icon: keyof typeof Ionicons.glyphMap, title: string, description: string, isLast?: boolean }) { return ( - - - - {label} + + + + + + {title} + {description} - - {unlimited ? '∞' : value} - ); } -function formatLimit(limit: number | string): string | number { - if (typeof limit === 'string') return limit; - if (limit === Infinity) return '∞'; - return limit; -} - const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: '#f8f9fa', + backgroundColor: '#F7F7F9', // Light gray-white background like Revolut }, loadingContainer: { flex: 1, @@ -401,22 +307,21 @@ const styles = StyleSheet.create({ flex: 1, }, scrollContent: { - padding: 16, - paddingBottom: 40, + paddingHorizontal: 20, + paddingTop: 20, + paddingBottom: 100, // Space for bottom button }, - description: { - fontSize: 16, - color: '#666666', + pageTitle: { + fontSize: 34, + fontWeight: '800', + color: '#1C1C1E', marginBottom: 24, - textAlign: 'center', - lineHeight: 22, + letterSpacing: -0.5, }, warningBanner: { flexDirection: 'row', alignItems: 'center', backgroundColor: '#fff3cd', - borderColor: '#ffeeba', - borderWidth: 1, padding: 12, borderRadius: 12, marginBottom: 20, @@ -426,187 +331,164 @@ const styles = StyleSheet.create({ fontSize: 14, marginLeft: 8, flex: 1, + fontWeight: '500', }, - planCard: { - backgroundColor: '#ffffff', + tabsContainer: { + flexDirection: 'row', + marginBottom: 24, + 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', + }, + darkCard: { + backgroundColor: '#1C1C1E', borderRadius: 24, - borderWidth: 2, - borderColor: 'transparent', padding: 24, - marginBottom: 20, - shadowColor: '#000000', + marginBottom: 32, + // Optional subtle dark shadow + shadowColor: '#000', shadowOffset: { width: 0, height: 8 }, - shadowOpacity: 0.06, + shadowOpacity: 0.2, shadowRadius: 16, - elevation: 6, - }, - currentPlanCard: { - borderColor: '#007AFF', - backgroundColor: '#f8fcff', + elevation: 8, }, - freePlanCard: { - borderColor: '#e1e5e9', - shadowOpacity: 0.02, - }, - planHeader: { + darkCardHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - marginBottom: 12, - }, - planTitleContainer: { - flexDirection: 'row', - alignItems: 'center', + marginBottom: 24, }, - planName: { - fontSize: 22, + darkCardTitle: { + fontSize: 32, fontWeight: '800', - color: '#1c1c1e', - }, - currentPlanName: { - color: '#007AFF', + color: '#FFFFFF', + letterSpacing: -0.5, }, - currentBadge: { - backgroundColor: '#007AFF', - paddingHorizontal: 12, + activeBadge: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#FFFFFF', + paddingHorizontal: 10, paddingVertical: 6, - borderRadius: 16, + borderRadius: 100, }, - currentBadgeText: { - color: '#ffffff', - fontSize: 12, + activeBadgeText: { + fontSize: 13, fontWeight: '700', - textTransform: 'uppercase', + color: '#1C1C1E', + marginLeft: 4, }, - planPrice: { - fontSize: 32, - fontWeight: '800', - color: '#1c1c1e', - marginBottom: 8, - }, - planDuration: { - fontSize: 16, + darkCardPrice: { + fontSize: 20, fontWeight: '600', - color: '#8e8e93', + color: '#FFFFFF', + marginBottom: 8, }, - planDescription: { + darkCardSubtitle: { fontSize: 15, - color: '#8e8e93', - marginBottom: 24, - lineHeight: 20, + color: '#A1A1A6', + fontWeight: '400', }, - features: { - marginBottom: 28, + sectionTitle: { + fontSize: 20, + fontWeight: '700', + color: '#1C1C1E', + marginBottom: 16, + letterSpacing: -0.3, }, - featureRow: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingVertical: 12, - borderBottomWidth: 1, - borderBottomColor: '#f2f2f7', + featuresCard: { + backgroundColor: '#FFFFFF', + borderRadius: 24, + paddingHorizontal: 20, + paddingVertical: 10, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.04, + shadowRadius: 12, + elevation: 2, }, - featureLabelContainer: { + featureItemContainer: { flexDirection: 'row', - alignItems: 'center', - flex: 1, + paddingVertical: 20, }, - featureLabel: { - fontSize: 15, - color: '#3a3a3c', - fontWeight: '500', - }, - featureValue: { - fontSize: 16, - fontWeight: '700', - color: '#1c1c1e', - }, - featureValueHighlight: { - color: '#007AFF', + featureItemBorder: { + borderBottomWidth: 1, + borderBottomColor: '#F2F2F7', }, - upgradeButton: { - backgroundColor: '#007AFF', - borderRadius: 16, - paddingVertical: 16, - alignItems: 'center', - shadowColor: '#007AFF', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.2, - shadowRadius: 8, - elevation: 4, + featureIconContainer: { + width: 32, + alignItems: 'flex-start', + justifyContent: 'flex-start', + paddingTop: 2, }, - currentButton: { - backgroundColor: '#f2f2f7', - shadowOpacity: 0, - elevation: 0, - }, - disabledButton: { - backgroundColor: '#e5e5ea', - shadowOpacity: 0, - elevation: 0, + featureTextContainer: { + flex: 1, }, - upgradeButtonText: { - color: '#ffffff', + featureTitle: { fontSize: 17, fontWeight: '700', + color: '#1C1C1E', + marginBottom: 6, + letterSpacing: -0.2, }, - currentButtonText: { - color: '#8e8e93', - }, - disabledButtonText: { - color: '#8e8e93', - }, - actionsSection: { - marginTop: 16, - }, - actionButton: { - borderRadius: 16, - paddingVertical: 16, - marginBottom: 12, + featureDescription: { + fontSize: 15, + color: '#8E8E93', + lineHeight: 20, }, - actionButtonContent: { - flexDirection: 'row', - alignItems: 'center', + bottomActionContainer: { + position: 'absolute', + bottom: 0, + left: 0, + right: 0, + paddingHorizontal: 20, + paddingTop: 16, + paddingBottom: Platform.OS === 'ios' ? 34 : 24, + backgroundColor: '#F7F7F9', // Match main bg + }, + mainButton: { + backgroundColor: '#1C1C1E', + borderRadius: 100, // Pill shape + height: 56, justifyContent: 'center', - }, - cancelButton: { - backgroundColor: '#ff3b30', - shadowColor: '#ff3b30', + alignItems: 'center', + shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.2, - shadowRadius: 8, + shadowOpacity: 0.15, + shadowRadius: 12, elevation: 4, }, - cancelButtonText: { - color: '#ffffff', - fontSize: 17, - fontWeight: '700', - }, - restoreButton: { - backgroundColor: '#ffffff', - borderWidth: 1, - borderColor: '#d1d1d6', + mainButtonDisabled: { + backgroundColor: '#E5E5EA', + shadowOpacity: 0, + elevation: 0, }, - restoreButtonText: { - color: '#007AFF', + mainButtonText: { + color: '#FFFFFF', fontSize: 17, fontWeight: '700', }, - gracePeriodInfo: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: '#fff3cd', - paddingHorizontal: 16, - paddingVertical: 12, - borderRadius: 12, - marginBottom: 16, - }, - gracePeriodText: { - fontSize: 15, - color: '#856404', - marginLeft: 8, - flex: 1, - fontWeight: '500', - lineHeight: 20, + mainButtonTextDisabled: { + color: '#8E8E93', }, }); From 7f7cee48c044d6b164b5cd945cd30587f2416552 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Mon, 27 Apr 2026 16:51:47 +0200 Subject: [PATCH 06/37] fix(subscriptions): prevent Intl ReferenceError on Android Hermes --- src/navigation/screens/SubscriptionPlans.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/navigation/screens/SubscriptionPlans.tsx b/src/navigation/screens/SubscriptionPlans.tsx index 95d3a06..6e3fa5b 100644 --- a/src/navigation/screens/SubscriptionPlans.tsx +++ b/src/navigation/screens/SubscriptionPlans.tsx @@ -111,8 +111,8 @@ export default function SubscriptionPlans() { // Helper to format limit for the new UI feature list const formatFeatureLimit = (daily: number | string, monthly: number | string) => { - const d = isUnlimitedPlan(daily) ? '∞' : daily; - const m = isUnlimitedPlan(monthly) ? '∞' : monthly; + const d = isUnlimitedPlan(daily) ? '∞' : String(daily); + const m = isUnlimitedPlan(monthly) ? '∞' : String(monthly); return `${d} ${t('common.daily', 'giornalieri')} • ${m} ${t('common.monthly', 'mensili')}`; }; @@ -236,7 +236,7 @@ export default function SubscriptionPlans() { icon="folder" title={t('subscriptionPlans.featCategoriesTitle', 'Organization')} description={t('subscriptionPlans.featCategoriesDesc', 'Organize tasks in up to {{count}} categories', { - count: isUnlimitedPlan(selectedPlan.limits.maxCategories) ? 'unlimited' : selectedPlan.limits.maxCategories + count: isUnlimitedPlan(selectedPlan.limits.maxCategories) ? 'unlimited' : String(selectedPlan.limits.maxCategories) })} isLast /> From 4c00204acee77459531464dd289158021272fd7f Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Mon, 27 Apr 2026 16:55:59 +0200 Subject: [PATCH 07/37] fix(i18n): update subscription plans screen title to Pricing --- src/locales/en.json | 30 ++++++++++-------------------- src/locales/it.json | 32 +++++++++++--------------------- 2 files changed, 21 insertions(+), 41 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index 044f7dd..65d545c 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": { diff --git a/src/locales/it.json b/src/locales/it.json index 2b327cc..a6453c0 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" } }, From 10d64ccf39546b13aebaa1554a64b2d1d8f01461 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Mon, 27 Apr 2026 16:56:36 +0200 Subject: [PATCH 08/37] chore(settings): clean up settings and subscription navigation --- src/components/Task/AddTask.tsx | 3 +- src/navigation/index.tsx | 11 ++-- src/navigation/screens/NotificationDebug.tsx | 65 +++++++++++++++++--- src/navigation/screens/Settings.tsx | 11 ++++ src/services/AppInitializer.ts | 14 +++++ src/services/revenueCatService.ts | 11 +++- src/services/taskService.ts | 13 +--- 7 files changed, 97 insertions(+), 31 deletions(-) diff --git a/src/components/Task/AddTask.tsx b/src/components/Task/AddTask.tsx index f32443e..0001211 100644 --- a/src/components/Task/AddTask.tsx +++ b/src/components/Task/AddTask.tsx @@ -169,8 +169,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/navigation/index.tsx b/src/navigation/index.tsx index 5ef83d7..f678d82 100644 --- a/src/navigation/index.tsx +++ b/src/navigation/index.tsx @@ -193,17 +193,17 @@ function NavigationHandler() { return false; // Lascia che React Navigation gestisca il back button }; - // Listener per sincronizzazione automatica al cambio schermata + // Listener per sincronizzazione automatica solo su schermate che ne hanno bisogno + const SYNC_SCREEN = ['Categories', 'Calendar20', 'Calendar']; const handleScreenChange = async ({ screenName, params }) => { - console.log(`[NAVIGATION] 🔄 Cambio schermata rilevato: ${screenName}`); + 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); }); }; @@ -228,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); 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} + + + )} + + navigation.navigate('NotificationDebug')} + > + + + Debug Notifiche + + + + { + console.log(`[RevenueCat] Package "${pkg.identifier}" — ${pkg.product.priceString} (${pkg.product.identifier})`); + }); + } else { + console.log('[RevenueCat] No current offering available'); + } return offerings; } catch (error) { - console.warn('Failed to fetch RevenueCat offerings:', error); + // SDK already logs the full error; keep our log at debug level + console.debug('RevenueCat offerings unavailable:', error.code); return null; } } diff --git a/src/services/taskService.ts b/src/services/taskService.ts index e7491cd..6c3f33b 100644 --- a/src/services/taskService.ts +++ b/src/services/taskService.ts @@ -352,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'; @@ -726,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", From bc939ac2cac614481d864ef25b581796fa154a22 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Mon, 27 Apr 2026 16:59:26 +0200 Subject: [PATCH 09/37] feat(settings): redesign Plan & Usage to match Revolut style --- src/navigation/screens/Settings.tsx | 266 ++++++++++++++++------------ 1 file changed, 155 insertions(+), 111 deletions(-) diff --git a/src/navigation/screens/Settings.tsx b/src/navigation/screens/Settings.tsx index 63f9d11..e9ac873 100644 --- a/src/navigation/screens/Settings.tsx +++ b/src/navigation/screens/Settings.tsx @@ -82,63 +82,68 @@ export default function Settings() { 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 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.effective_plan.toUpperCase()} + + + + {!isFree && } + + {planData.effective_plan.toUpperCase()} + - - - {/* Daily text messages */} - - - {t('planUsage.dailyMessages')} - - {textUnlimited - ? t('planUsage.unlimited') - : String(planData.chat_text_daily_limit)} + + + + {t('settings.plan.active', 'Active')} - {/* Voice access */} - - - {t('planUsage.voiceRequests')} - - {planData.chat_voice_monthly_limit === null - ? t('planUsage.unlimited') - : String(planData.chat_voice_monthly_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)} - {/* Upgrade CTA for FREE */} - {planData.effective_plan.toLowerCase() === 'free' && ( + {isFree && ( navigation.navigate('SubscriptionPlans')} > - {t('planUsage.upgrade')} + {t('planUsage.upgrade', 'Upgrade to Premium')} )} @@ -149,7 +154,27 @@ export default function Settings() { - + + + {/* 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')} @@ -166,14 +191,6 @@ export default function Settings() { - {/* Plan & Usage Section */} - - {t('planUsage.sectionTitle')} - - - {renderPlanSection()} - - {/* AI Section */} {t('settings.sections.ai')} @@ -287,6 +304,7 @@ export default function Settings() { + ); @@ -320,6 +338,9 @@ const styles = StyleSheet.create({ content: { flex: 1, }, + scrollContent: { + paddingBottom: 40, + }, menuItem: { flexDirection: 'row', alignItems: 'center', @@ -353,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, + premiumCard: { + borderRadius: 24, + padding: 20, + marginBottom: 8, + }, + premiumCardFree: { + backgroundColor: '#ffffff', borderWidth: 1, - borderColor: '#e1e5e9', + borderColor: '#e5e5ea', + shadowColor: '#000', + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.05, + shadowRadius: 8, + elevation: 2, }, - planLoadingText: { - fontSize: 14, - color: '#666666', - fontFamily: 'System', - marginTop: 8, - textAlign: 'center', + premiumCardActive: { + backgroundColor: '#1C1C1E', + shadowColor: '#000', + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.2, + shadowRadius: 12, + elevation: 6, }, - planErrorText: { - fontSize: 14, - color: '#666666', - fontFamily: 'System', - textAlign: 'center', - }, - 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, + }, + textWhite: { + color: '#FFFFFF', }, - planBadge: { - backgroundColor: '#000000', - borderRadius: 8, - paddingHorizontal: 10, + 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', - }, - progressBarFill: { - height: 6, - backgroundColor: '#000000', - borderRadius: 3, + usageValue: { + fontSize: 18, + fontWeight: '700', + color: '#1C1C1E', }, - progressBarWarning: { - backgroundColor: '#FF6B35', + usageDivider: { + width: 1, + height: '100%', + backgroundColor: 'rgba(142, 142, 147, 0.2)', + marginHorizontal: 16, }, - 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, + } }); From c53b974ced6fe2809f8bc607034e5835ab0689de Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Mon, 27 Apr 2026 17:25:35 +0200 Subject: [PATCH 10/37] fix(auth): reset navigation stack after login and dismiss Google browser - Replace navigation.navigate with navigation.reset in Login to prevent back gesture returning to login screen - Force dismiss WebBrowser after Google OAuth to prevent browser tab remaining in Android back stack - Set showInRecents to false on auth session --- src/navigation/screens/Login.tsx | 5 ++++- src/services/googleSignInService.ts | 8 +++++++- 2 files changed, 11 insertions(+), 2 deletions(-) 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/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...'); From 42b3c054a357166b093caabbf5c922586f50d2c6 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Tue, 28 Apr 2026 11:55:26 +0200 Subject: [PATCH 11/37] feat(subscription): add multi-billing support (monthly/annual) and UI improvements --- src/constants/planLimits.ts | 30 ++- src/locales/en.json | 29 ++- src/locales/it.json | 29 ++- src/navigation/screens/SubscriptionPlans.tsx | 231 +++++++++++++++---- 4 files changed, 271 insertions(+), 48 deletions(-) diff --git a/src/constants/planLimits.ts b/src/constants/planLimits.ts index 1c388c7..2a379dd 100644 --- a/src/constants/planLimits.ts +++ b/src/constants/planLimits.ts @@ -1,3 +1,10 @@ +export type BillingPeriod = 'monthly' | 'annual'; + +export interface PlanProductIds { + monthly: string; + annual: string; +} + export interface PlanLimits { chatTextDaily: number; chatTextMonthly: number; @@ -11,13 +18,21 @@ export interface Plan { id: 'free' | 'pro' | 'premium'; name: string; limits: PlanLimits; - productId?: string; + 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', string | undefined> = { +export const PLAN_PRODUCT_IDS: Record<'free' | 'pro' | 'premium', PlanProductIds | undefined> = { free: undefined, - pro: 'mytaskly_pro_monthly', - premium: 'mytaskly_premium_monthly', + pro: { + monthly: 'mytaskly_pro_monthly', + annual: 'mytaskly_pro_annual', + }, + premium: { + monthly: 'mytaskly_premium_monthly', + annual: 'mytaskly_premium_annual', + }, }; export const PLANS: Record<'free' | 'pro' | 'premium', Plan> = { @@ -32,11 +47,12 @@ export const PLANS: Record<'free' | 'pro' | 'premium', Plan> = { aiModel: 'base', maxCategories: 5, }, + getProductId: () => undefined, }, pro: { id: 'pro', name: 'Pro', - productId: PLAN_PRODUCT_IDS.pro, + productIds: PLAN_PRODUCT_IDS.pro, limits: { chatTextDaily: 50, chatTextMonthly: 250, @@ -45,11 +61,12 @@ export const PLANS: Record<'free' | 'pro' | 'premium', Plan> = { aiModel: 'advanced', maxCategories: Infinity, }, + getProductId: (period: BillingPeriod) => PLAN_PRODUCT_IDS.pro?.[period], }, premium: { id: 'premium', name: 'Premium', - productId: PLAN_PRODUCT_IDS.premium, + productIds: PLAN_PRODUCT_IDS.premium, limits: { chatTextDaily: Infinity, chatTextMonthly: 400, @@ -58,5 +75,6 @@ export const PLANS: Record<'free' | 'pro' | 'premium', Plan> = { aiModel: 'advanced', maxCategories: Infinity, }, + getProductId: (period: BillingPeriod) => PLAN_PRODUCT_IDS.premium?.[period], }, }; diff --git a/src/locales/en.json b/src/locales/en.json index 65d545c..23ed2c8 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -982,6 +982,33 @@ "restoreSuccess": "Purchases restored successfully!", "restoreError": "Unable to restore purchases", "noPurchasesFound": "No purchases found to restore", - "restorePurchases": "Restore purchases" + "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 {{count}} 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 a6453c0..dc4569f 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -982,6 +982,33 @@ "restoreSuccess": "Acquisti ripristinati con successo!", "restoreError": "Impossibile ripristinare gli acquisti", "noPurchasesFound": "Nessun acquisto da ripristinare", - "restorePurchases": "Ripristina acquisti" + "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 {{count}} 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/screens/SubscriptionPlans.tsx b/src/navigation/screens/SubscriptionPlans.tsx index 6e3fa5b..f2282ab 100644 --- a/src/navigation/screens/SubscriptionPlans.tsx +++ b/src/navigation/screens/SubscriptionPlans.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { StyleSheet, View, @@ -22,7 +22,7 @@ import RevenueCatService, { type Offerings, type PurchasesPackage, } from '../../services/revenueCatService'; -import { PLANS, Plan } from '../../constants/planLimits'; +import { PLANS, Plan, BillingPeriod } from '../../constants/planLimits'; export default function SubscriptionPlans() { const { t } = useTranslation(); @@ -31,9 +31,8 @@ export default function SubscriptionPlans() { const [offerings, setOfferings] = useState(null); const [loading, setLoading] = useState(true); const [actionLoading, setActionLoading] = useState(false); - - // New state for the Revolut-style tab selector const [selectedPlanId, setSelectedPlanId] = useState('free'); + const [billingPeriod, setBillingPeriod] = useState('monthly'); const loadData = useCallback(async () => { try { @@ -44,8 +43,7 @@ export default function SubscriptionPlans() { ]); setPlanData(userPlan); setOfferings(rcOfferings); - - // Default selected plan to current active plan, or free + if (userPlan?.effective_plan) { setSelectedPlanId(userPlan.effective_plan); } @@ -61,12 +59,13 @@ export default function SubscriptionPlans() { }, [loadData]); const handlePurchase = useCallback( - async (plan: Plan) => { - if (plan.id === 'free') return; + async (plan: Plan, period: BillingPeriod) => { + if (plan.id === 'free') return; - if (!plan.productId || !offerings) { + const productId = plan.getProductId(period); + if (!productId || !offerings) { Alert.alert( - t('common.error', 'Errore'), + t('common.messages.error', 'Errore'), t('subscriptionPlans.offlineError', 'Servizio offline o piano non disponibile.') ); return; @@ -76,7 +75,7 @@ export default function SubscriptionPlans() { setActionLoading(true); const packageToPurchase = offerings.current?.availablePackages.find( - (pkg) => pkg.identifier === plan.productId + (pkg) => pkg.product.identifier === productId ); if (!packageToPurchase) { @@ -93,7 +92,7 @@ export default function SubscriptionPlans() { } Alert.alert( t('subscriptionPlans.purchaseError', 'Errore durante l\'acquisto'), - error?.message || t('common.error', 'Si è verificato un errore sconosciuto.') + error?.message || t('common.messages.error', 'Si è verificato un errore sconosciuto.') ); } finally { setActionLoading(false); @@ -109,13 +108,53 @@ export default function SubscriptionPlans() { [planData] ); - // Helper to format limit for the new UI feature list const formatFeatureLimit = (daily: number | string, monthly: number | string) => { 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; + return offerings.current?.availablePackages.find( + (p) => 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; + + const monthlyEquivPrice = useMemo(() => { + if (billingPeriod !== 'annual' || !pkg) return null; + return pkg.product.price / 12; + }, [billingPeriod, pkg]); + if (loading) { return ( @@ -127,22 +166,27 @@ export default function SubscriptionPlans() { ); } - 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: PurchasesPackage | undefined = offerings?.current?.availablePackages.find( - (p) => p.identifier === selectedPlan.productId - ); - - let priceStr = isFreeSelected ? t('common.free', 'Complimentary') : '—'; + let priceStr = isFreeSelected ? t('common.free', 'Free') : '—'; let subtitleStr = t('subscriptionPlans.freeDescription', 'Just the basics'); + let trialInfo: string | null = null; if (!isFreeSelected) { if (pkg) { priceStr = pkg.product.priceString; subtitleStr = pkg.product.description || t('subscriptionPlans.premiumDesc', 'Unlock all premium features'); + + // Check for introductory price (free trial) + const intro = pkg.product.introductoryPrice; + if (intro) { + trialInfo = t('subscriptionPlans.freeTrial', 'Free trial'); + } + + if (billingPeriod === 'annual' && monthlyEquivPrice !== null) { + const currency = pkg.product.priceString.replace(/[\d.,\s]/g, '').trim(); + const formatted = monthlyEquivPrice.toFixed(2).replace('.', ','); + priceStr = `${currency}${formatted}`; + subtitleStr = t('subscriptionPlans.perMonth', '/month') + ' — ' + t('subscriptionPlans.annual', 'Annual'); + } } else { subtitleStr = t('subscriptionPlans.offlineDescription', 'Dettagli non disponibili al momento.'); } @@ -155,9 +199,40 @@ export default function SubscriptionPlans() { - - {/* Title */} - {t('subscriptionPlans.selectPlan', 'Select plan')} + + {/* 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 && ( @@ -198,7 +273,15 @@ export default function SubscriptionPlans() { )} - + + {/* Free Trial Badge */} + {trialInfo && !isSelectedPlanCurrent && ( + + + {trialInfo} + + )} + {priceStr} {subtitleStr} @@ -207,8 +290,8 @@ export default function SubscriptionPlans() { {t('subscriptionPlans.topFeatures', 'Top features')} - - - - - handlePurchase(selectedPlan)} + onPress={() => handlePurchase(selectedPlan, billingPeriod)} disabled={isSelectedPlanCurrent || actionLoading || offlineOrMissing || isFreeSelected} > {actionLoading ? ( @@ -296,7 +379,7 @@ function FeatureItem({ icon, title, description, isLast }: { icon: keyof typeof const styles = StyleSheet.create({ container: { flex: 1, - backgroundColor: '#F7F7F9', // Light gray-white background like Revolut + backgroundColor: '#F7F7F9', }, loadingContainer: { flex: 1, @@ -309,15 +392,20 @@ const styles = StyleSheet.create({ scrollContent: { paddingHorizontal: 20, paddingTop: 20, - paddingBottom: 100, // Space for bottom button + paddingBottom: 100, }, pageTitle: { fontSize: 34, fontWeight: '800', color: '#1C1C1E', - marginBottom: 24, letterSpacing: -0.5, }, + titleRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 24, + }, warningBanner: { flexDirection: 'row', alignItems: 'center', @@ -335,7 +423,7 @@ const styles = StyleSheet.create({ }, tabsContainer: { flexDirection: 'row', - marginBottom: 24, + marginBottom: 16, alignItems: 'center', }, tabButton: { @@ -360,12 +448,59 @@ const styles = StyleSheet.create({ 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, - // Optional subtle dark shadow shadowColor: '#000', shadowOffset: { width: 0, height: 8 }, shadowOpacity: 0.2, @@ -398,6 +533,22 @@ const styles = StyleSheet.create({ color: '#1C1C1E', marginLeft: 4, }, + trialBadge: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(52, 199, 89, 0.15)', + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 12, + marginBottom: 16, + alignSelf: 'flex-start', + }, + trialBadgeText: { + color: '#34C759', + fontSize: 14, + fontWeight: '600', + marginLeft: 6, + }, darkCardPrice: { fontSize: 20, fontWeight: '600', @@ -464,11 +615,11 @@ const styles = StyleSheet.create({ paddingHorizontal: 20, paddingTop: 16, paddingBottom: Platform.OS === 'ios' ? 34 : 24, - backgroundColor: '#F7F7F9', // Match main bg + backgroundColor: '#F7F7F9', }, mainButton: { backgroundColor: '#1C1C1E', - borderRadius: 100, // Pill shape + borderRadius: 100, height: 56, justifyContent: 'center', alignItems: 'center', From 194df584d3dae2903b17060d5962ccc6f4a9d6a4 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Tue, 28 Apr 2026 11:55:34 +0200 Subject: [PATCH 12/37] style(ui): enhance Categories and TaskList with animations, skeletons, and improved UI --- src/components/Category/CategoryCard.tsx | 2 +- src/components/Category/CategoryView.tsx | 354 ++++++++++++------ src/components/TaskList/ActiveFilters.tsx | 17 +- src/components/TaskList/FilterModal.tsx | 110 ++++-- src/components/TaskList/TaskListContainer.tsx | 207 +++++++--- src/components/TaskList/styles.ts | 129 +++---- src/navigation/index.tsx | 18 +- src/navigation/screens/Categories.tsx | 47 ++- 8 files changed, 608 insertions(+), 276 deletions(-) diff --git a/src/components/Category/CategoryCard.tsx b/src/components/Category/CategoryCard.tsx index f716ba3..0ef943f 100644 --- a/src/components/Category/CategoryCard.tsx +++ b/src/components/Category/CategoryCard.tsx @@ -123,7 +123,7 @@ const CategoryCard: React.FC = ({ badgeType={badgeType} /> - {(isShared || !isOwned) && ( + {!isOwned && ( 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 +172,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 +194,7 @@ const CategoryView = forwardRef( forceRefresh: boolean = false, silent: boolean = false ) => { + const startedAt = Date.now(); if (!silent) { setLoading(true); } @@ -98,61 +211,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 && ( + + + Aggiungi la tua prima categoria per iniziare!{"\n"} + + + oppure{"\n"} + + { + navigation.navigate("Login"); + }} + > + Vai al login + )} - + ); } ); @@ -207,23 +338,6 @@ 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, @@ -231,30 +345,27 @@ const styles = StyleSheet.create({ }, 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 +377,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/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/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..6047c7b 100644 --- a/src/components/TaskList/TaskListContainer.tsx +++ b/src/components/TaskList/TaskListContainer.tsx @@ -1,6 +1,9 @@ -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, Text, StyleSheet } 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'; @@ -44,15 +47,68 @@ export const TaskListContainer = ({ const [ordineScadenza, setOrdineScadenza] = useState("Recente"); const [isLoading, setIsLoading] = useState(true); const [modalVisible, setModalVisible] = useState(false); + + // Loading animation values + const dot1 = useRef(new Animated.Value(0)).current; + const dot2 = useRef(new Animated.Value(0)).current; + const dot3 = useRef(new Animated.Value(0)).current; + const fadeOverlay = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (!isLoading) return; + + Animated.sequence([ + Animated.timing(fadeOverlay, { toValue: 1, duration: 300, useNativeDriver: true }), + ]).start(); + + const loop = () => { + Animated.loop( + Animated.sequence([ + Animated.timing(dot1, { toValue: 1, duration: 400, useNativeDriver: true, easing: Easing.ease }), + Animated.timing(dot2, { toValue: 1, duration: 400, useNativeDriver: true, easing: Easing.ease }), + Animated.timing(dot3, { toValue: 1, duration: 400, useNativeDriver: true, easing: Easing.ease }), + Animated.delay(200), + Animated.timing(dot1, { toValue: 0, duration: 400, useNativeDriver: true, easing: Easing.ease }), + Animated.timing(dot2, { toValue: 0, duration: 400, useNativeDriver: true, easing: Easing.ease }), + Animated.timing(dot3, { toValue: 0, duration: 400, useNativeDriver: true, easing: Easing.ease }), + Animated.delay(200), + ]), + ).start(); + }; + loop(); + + return () => { + dot1.stopAnimation(); + dot2.stopAnimation(); + dot3.stopAnimation(); + fadeOverlay.stopAnimation(); + }; + }, [isLoading]); const [formVisible, setFormVisible] = useState(false); // 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 +575,27 @@ export const TaskListContainer = ({ return ( - setModalVisible(true)} - /> - {isLoading ? ( - + + + + {[dot1, dot2, dot3].map((anim, i) => ( + + ))} + + + ) : ( {/* Modal dei filtri */} @@ -540,47 +610,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) */} + + {t('taskList.sections.todo') || 'Da fare'} + + {/* 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 ( + + ); + })} + + ) : ( + + + {t('taskList.sections.emptyTodo') || 'Nessun task da fare'} + + )} + + + {/* Sezione task completati (collapsabile in basso) */} {completedTasks.length > 0 && ( ); }; + +const loadingStyles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: '#ffffff', + justifyContent: 'center', + alignItems: 'center', + zIndex: 10, + }, + content: { + alignItems: 'center', + }, + dotsRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 10, + marginBottom: 20, + }, + dot: { + width: 12, + height: 12, + borderRadius: 6, + backgroundColor: '#000000', + }, + label: { + fontSize: 15, + fontWeight: '300', + color: '#999999', + fontFamily: 'System', + letterSpacing: -0.3, + }, +}); diff --git a/src/components/TaskList/styles.ts b/src/components/TaskList/styles.ts index c6ec051..3211da0 100644 --- a/src/components/TaskList/styles.ts +++ b/src/components/TaskList/styles.ts @@ -3,7 +3,8 @@ import { StyleSheet } from 'react-native'; export const styles = StyleSheet.create({ container: { flex: 1, - padding: 20, + paddingHorizontal: 16, + paddingTop: 0, backgroundColor: '#ffffff', }, headerContainer: { @@ -38,33 +39,49 @@ export const styles = StyleSheet.create({ // Stili per il modal modalOverlay: { flex: 1, - backgroundColor: 'rgba(0,0,0,0.5)', - justifyContent: 'center', + backgroundColor: 'transparent', + justifyContent: 'flex-end', alignItems: 'center', - }, modalContent: { - width: '90%', + }, + modalContent: { + width: '100%', backgroundColor: '#ffffff', - borderRadius: 24, + borderTopLeftRadius: 24, + borderTopRightRadius: 24, overflow: 'hidden', - maxHeight: '80%', + maxHeight: '90%', shadowColor: "#000", - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.08, + shadowOffset: { width: 0, height: -4 }, + shadowOpacity: 0.1, shadowRadius: 12, - elevation: 3, + elevation: 10, + paddingBottom: 20, // Per dispositivi con notch + }, + dragHandleContainer: { + alignItems: 'center', + paddingTop: 12, + paddingBottom: 8, + backgroundColor: '#ffffff', + }, + dragHandle: { + width: 40, + height: 5, + backgroundColor: '#e1e5e9', + borderRadius: 3, }, modalHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - padding: 24, + paddingVertical: 14, + paddingHorizontal: 20, borderBottomWidth: 1, borderBottomColor: '#e1e5e9', backgroundColor: '#ffffff', }, modalTitle: { - fontSize: 24, - fontWeight: '300', + fontSize: 20, + fontWeight: '400', color: '#000000', fontFamily: "System", letterSpacing: -0.5, @@ -76,6 +93,7 @@ export const styles = StyleSheet.create({ alignItems: 'center', borderRadius: 15, backgroundColor: 'transparent', + marginTop: -4, // per centrarla visivamente con il testo }, closeButtonText: { fontSize: 24, @@ -83,11 +101,14 @@ export const styles = StyleSheet.create({ fontWeight: '300', }, modalBody: { - padding: 24, - maxHeight: 400, + paddingHorizontal: 20, + paddingTop: 12, + paddingBottom: 24, + maxHeight: 450, }, modalFooter: { - padding: 24, + paddingVertical: 16, + paddingHorizontal: 20, borderTopWidth: 1, borderTopColor: '#e1e5e9', alignItems: 'center', @@ -111,30 +132,33 @@ export const styles = StyleSheet.create({ fontFamily: "System", }, // Stili per i filtri filterTitle: { - fontSize: 18, - fontWeight: '400', + fontSize: 17, + fontWeight: '500', color: '#000000', - marginBottom: 16, + marginBottom: 8, fontFamily: "System", }, filterSection: { - padding: 20, + paddingVertical: 16, borderBottomWidth: 1, borderBottomColor: '#e1e5e9', - marginBottom: 12, }, chipsContainer: { flexDirection: 'row', paddingVertical: 8, flexWrap: 'wrap', }, + horizontalScrollContainer: { + paddingVertical: 8, + paddingRight: 20, // Per lasciare spazio alla fine dello scroll + }, filterChip: { borderWidth: 1.5, borderColor: '#e1e5e9', borderRadius: 24, - paddingVertical: 12, - paddingHorizontal: 20, - marginRight: 10, + paddingVertical: 10, + paddingHorizontal: 16, + marginRight: 8, marginBottom: 8, backgroundColor: '#ffffff', shadowColor: "#000", @@ -186,58 +210,37 @@ export const styles = StyleSheet.create({ }, // Stili per i filtri attivi activeFilterContainer: { - backgroundColor: '#ffffff', - padding: 20, - borderRadius: 16, - marginBottom: 20, + backgroundColor: '#f8f8f8', + paddingVertical: 10, + paddingHorizontal: 12, + borderRadius: 12, + marginBottom: 8, borderWidth: 1, borderColor: '#e1e5e9', - shadowColor: "#000", - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.04, - shadowRadius: 4, - elevation: 1, }, - activeFilterText: { - color: '#000000', - fontWeight: '400', - fontSize: 16, - marginBottom: 12, - fontFamily: "System", - }, activeFilterChips: { + activeFilterChips: { flexDirection: 'row', - flexWrap: 'wrap', }, activeChip: { flexDirection: 'row', alignItems: 'center', - backgroundColor: '#f8f8f8', - paddingVertical: 10, - paddingHorizontal: 16, - borderRadius: 24, - marginRight: 10, - marginBottom: 8, - borderWidth: 1, - borderColor: '#e1e5e9', + backgroundColor: '#000000', // Sfondo nero per risaltare che è attivo + paddingVertical: 8, + paddingHorizontal: 14, + borderRadius: 20, + marginRight: 8, shadowColor: "#000", shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.04, - shadowRadius: 4, - elevation: 1, + shadowOpacity: 0.1, + shadowRadius: 3, + elevation: 2, }, activeChipText: { - color: '#000000', + color: '#ffffff', // Testo bianco fontSize: 14, - fontWeight: '400', - marginRight: 8, + fontWeight: '500', fontFamily: "System", }, - clearFilterButton: { - color: '#666666', - fontSize: 18, - fontWeight: '400', - marginLeft: 4, - }, emptyListContainer: { padding: 40, alignItems: 'center', @@ -280,8 +283,8 @@ export const styles = StyleSheet.create({ fontSize: 20, fontWeight: '400', color: '#000000', - marginTop: 20, - marginBottom: 16, + marginTop: 10, + marginBottom: 12, paddingLeft: 8, fontFamily: "System", letterSpacing: -0.3, diff --git a/src/navigation/index.tsx b/src/navigation/index.tsx index f678d82..ad5d324 100644 --- a/src/navigation/index.tsx +++ b/src/navigation/index.tsx @@ -67,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; @@ -136,7 +136,7 @@ function HomeTabs() { return ; }, - tabBarActiveTintColor: "#007AFF", + tabBarActiveTintColor: "#000000", tabBarInactiveTintColor: "gray", headerShown: false, })} @@ -195,7 +195,7 @@ function NavigationHandler() { // Listener per sincronizzazione automatica solo su schermate che ne hanno bisogno const SYNC_SCREEN = ['Categories', 'Calendar20', 'Calendar']; - const handleScreenChange = async ({ screenName, params }) => { + const handleScreenChange = async ({ screenName, params }: { screenName: string; params: any }) => { if (!SYNC_SCREEN.includes(screenName)) return; syncAllData() @@ -444,17 +444,23 @@ function AppStack() { ({ title: String(route.params?.category_name || '') })} /> 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(); @@ -62,21 +78,30 @@ export default function Categories() { {t("categories.title")} - + + } + > - - - - + Date: Tue, 28 Apr 2026 11:55:56 +0200 Subject: [PATCH 13/37] style(ui): update Home title weight and minor task component improvements --- src/components/Task/AddTask.tsx | 44 ++++ src/components/Task/CompletedTasksList.tsx | 235 ++++++++++++++++----- src/navigation/screens/Home.tsx | 2 +- 3 files changed, 229 insertions(+), 52 deletions(-) diff --git a/src/components/Task/AddTask.tsx b/src/components/Task/AddTask.tsx index 0001211..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(""); 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/navigation/screens/Home.tsx b/src/navigation/screens/Home.tsx index 719b42c..2c55a6a 100644 --- a/src/navigation/screens/Home.tsx +++ b/src/navigation/screens/Home.tsx @@ -1010,7 +1010,7 @@ const styles = StyleSheet.create({ }, mainTitle: { fontSize: 28, - fontWeight: "200", + fontWeight: "700", color: "#000000", fontFamily: "System", letterSpacing: -1.5, From 8527229853bef084ae2aa65f45e91d0210589f8d Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Tue, 28 Apr 2026 14:18:01 +0200 Subject: [PATCH 14/37] chore(openspec): add UI pattern audit matrix baseline Document duplicated UI patterns across Categories, TaskList, Calendar, Calendar20, and Home, and mark task 1.1 as completed to establish the migration baseline. Made-with: Cursor --- .../audit-ui-matrix.md | 23 ++++++++ .../reusable-ui-foundation-plan/tasks.md | 54 +++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 openspec/changes/reusable-ui-foundation-plan/audit-ui-matrix.md create mode 100644 openspec/changes/reusable-ui-foundation-plan/tasks.md 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/tasks.md b/openspec/changes/reusable-ui-foundation-plan/tasks.md new file mode 100644 index 0000000..242f8e8 --- /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) +- [ ] 1.2 Definire naming convention e cartelle target (`src/theme`, `src/components/UI/foundation`) per token e primitive +- [ ] 1.3 Creare checklist visuale di validazione per schermata (tipografia, spazi, elevazione, stati di caricamento/vuoto, azioni) + +## 2. Fondazioni (token + primitive) + +- [ ] 2.1 Implementare `src/theme/tokens.ts` con scale di spacing, typography roles, colori semantici neutrali, radius, elevation +- [ ] 2.2 Implementare `AppText` con varianti (`display`, `title`, `subtitle`, `body`, `caption`, `label`) basate su token +- [ ] 2.3 Implementare `ScreenContainer` e `ContentContainer` per sostituire wrapper ripetuti `backgroundColor/padding` +- [ ] 2.4 Implementare `ScreenHeader` con supporto titolo, azioni destre, varianti allineamento +- [ ] 2.5 Implementare `CardSurface` con varianti (`default`, `outlined`, `interactive`) e supporto accent border + +## 3. Pattern composabili condivisi + +- [ ] 3.1 Implementare `SectionHeader` (titolo + action slot + opzionale subtitle) +- [ ] 3.2 Implementare `StatusChip`/`MetaChip` per stati task, categoria, sync +- [ ] 3.3 Implementare `LoadingState` con varianti `spinner` e `dots` riusabili +- [ ] 3.4 Implementare `EmptyState` con icona, titolo, descrizione e CTA opzionale +- [ ] 3.5 Implementare `ModalShell` con header/body/footer slot e gestione safe-area +- [ ] 3.6 Implementare `InputShell` (row con leading/trailing action e text input) per pattern usato in `Home` + +## 4. Migrazione schermate prioritarie + +- [ ] 4.1 Migrare `Categories` a `ScreenContainer + ScreenHeader + ContentContainer` e uniformare spazi e titolo +- [ ] 4.2 Migrare componenti categoria principali (`CategoryCard`/vista lista) a `CardSurface` e `SectionHeader` +- [ ] 4.3 Migrare `TaskListContainer` a `LoadingState`, `EmptyState`, `SectionHeader`, chip stato/filtro condivisi +- [ ] 4.4 Migrare `TaskCard` verso composizione `CardSurface + AppText + MetaChip` mantenendo comportamento corrente +- [ ] 4.5 Migrare `CalendarView` a loader/empty/sync chip condivisi e header standardizzato +- [ ] 4.6 Verificare `Calendar20View` su container/header coerenti e compatibilita con pattern foundation +- [ ] 4.7 Applicare hardening su `Home` (header actions, loading bubble pattern, input shell) senza alterare flussi chat + +## 5. Validazione, cleanup e adozione + +- [ ] 5.1 Eseguire smoke test manuale per `Categories`, `TaskList`, `Calendar`, `Home` dopo ogni slice di migrazione +- [ ] 5.2 Rimuovere stili duplicati e inline obsolete nelle schermate migrate +- [ ] 5.3 Aggiungere documentazione d’uso dei nuovi componenti in `src/components/UI/foundation/README.md` +- [ ] 5.4 Definire lista “do/don’t” per evitare nuovi macro-componenti e favorire composizione + +## 6. Lista componenti da creare + +- [ ] 6.1 `AppText` +- [ ] 6.2 `ScreenContainer` +- [ ] 6.3 `ContentContainer` +- [ ] 6.4 `ScreenHeader` +- [ ] 6.5 `CardSurface` +- [ ] 6.6 `SectionHeader` +- [ ] 6.7 `StatusChip` / `MetaChip` +- [ ] 6.8 `LoadingState` (`spinner`, `dots`) +- [ ] 6.9 `EmptyState` +- [ ] 6.10 `ModalShell` +- [ ] 6.11 `InputShell` +- [ ] 6.12 `IconActionButton` From 2150e5c54b3a8df80d9f1418b9e787bd907096fc Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Tue, 28 Apr 2026 14:18:26 +0200 Subject: [PATCH 15/37] chore(ui-foundation): define naming conventions and target folders Establish the shared naming rules and folder responsibilities for `src/theme` and `src/components/UI/foundation`, and mark task 1.2 as completed. Made-with: Cursor --- .../reusable-ui-foundation-plan/tasks.md | 2 +- src/components/UI/foundation/CONVENTIONS.md | 22 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) create mode 100644 src/components/UI/foundation/CONVENTIONS.md diff --git a/openspec/changes/reusable-ui-foundation-plan/tasks.md b/openspec/changes/reusable-ui-foundation-plan/tasks.md index 242f8e8..b77af36 100644 --- a/openspec/changes/reusable-ui-foundation-plan/tasks.md +++ b/openspec/changes/reusable-ui-foundation-plan/tasks.md @@ -1,7 +1,7 @@ ## 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) -- [ ] 1.2 Definire naming convention e cartelle target (`src/theme`, `src/components/UI/foundation`) per token e primitive +- [x] 1.2 Definire naming convention e cartelle target (`src/theme`, `src/components/UI/foundation`) per token e primitive - [ ] 1.3 Creare checklist visuale di validazione per schermata (tipografia, spazi, elevazione, stati di caricamento/vuoto, azioni) ## 2. Fondazioni (token + primitive) 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`. From 3acd2a96be73869ddc3f2646c5cd8f6956bd138e Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Tue, 28 Apr 2026 14:18:43 +0200 Subject: [PATCH 16/37] chore(openspec): add visual validation checklist for screen migration Add a reusable UI validation checklist for Categories, TaskList, Calendar, and Home, and mark task 1.3 as completed. Made-with: Cursor --- .../reusable-ui-foundation-plan/tasks.md | 2 +- .../visual-validation-checklist.md | 34 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 openspec/changes/reusable-ui-foundation-plan/visual-validation-checklist.md diff --git a/openspec/changes/reusable-ui-foundation-plan/tasks.md b/openspec/changes/reusable-ui-foundation-plan/tasks.md index b77af36..845976d 100644 --- a/openspec/changes/reusable-ui-foundation-plan/tasks.md +++ b/openspec/changes/reusable-ui-foundation-plan/tasks.md @@ -2,7 +2,7 @@ - [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 -- [ ] 1.3 Creare checklist visuale di validazione per schermata (tipografia, spazi, elevazione, stati di caricamento/vuoto, azioni) +- [x] 1.3 Creare checklist visuale di validazione per schermata (tipografia, spazi, elevazione, stati di caricamento/vuoto, azioni) ## 2. Fondazioni (token + primitive) 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. From aaabad1c7fd16b609ba90678f7e328ebcdcb2a71 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Tue, 28 Apr 2026 14:19:11 +0200 Subject: [PATCH 17/37] feat(ui-foundation): add shared design tokens Introduce spacing, radius, elevation, color, and typography tokens in `src/theme/tokens.ts` and mark task 2.1 as completed for the foundation rollout. Made-with: Cursor --- .../reusable-ui-foundation-plan/tasks.md | 2 +- src/theme/tokens.ts | 109 ++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 src/theme/tokens.ts diff --git a/openspec/changes/reusable-ui-foundation-plan/tasks.md b/openspec/changes/reusable-ui-foundation-plan/tasks.md index 845976d..ec61768 100644 --- a/openspec/changes/reusable-ui-foundation-plan/tasks.md +++ b/openspec/changes/reusable-ui-foundation-plan/tasks.md @@ -6,7 +6,7 @@ ## 2. Fondazioni (token + primitive) -- [ ] 2.1 Implementare `src/theme/tokens.ts` con scale di spacing, typography roles, colori semantici neutrali, radius, elevation +- [x] 2.1 Implementare `src/theme/tokens.ts` con scale di spacing, typography roles, colori semantici neutrali, radius, elevation - [ ] 2.2 Implementare `AppText` con varianti (`display`, `title`, `subtitle`, `body`, `caption`, `label`) basate su token - [ ] 2.3 Implementare `ScreenContainer` e `ContentContainer` per sostituire wrapper ripetuti `backgroundColor/padding` - [ ] 2.4 Implementare `ScreenHeader` con supporto titolo, azioni destre, varianti allineamento 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; From 02b5a90dc3cdb64e9793b5991266aaad6f99cf99 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Tue, 28 Apr 2026 14:19:36 +0200 Subject: [PATCH 18/37] feat(ui-foundation): add AppText typography primitive Introduce the reusable `AppText` component backed by shared typography tokens and mark tasks 2.2 and 6.1 as completed. Made-with: Cursor --- .../reusable-ui-foundation-plan/tasks.md | 4 +- src/components/UI/foundation/AppText.tsx | 41 +++++++++++++++++++ 2 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 src/components/UI/foundation/AppText.tsx diff --git a/openspec/changes/reusable-ui-foundation-plan/tasks.md b/openspec/changes/reusable-ui-foundation-plan/tasks.md index ec61768..9687e6b 100644 --- a/openspec/changes/reusable-ui-foundation-plan/tasks.md +++ b/openspec/changes/reusable-ui-foundation-plan/tasks.md @@ -7,7 +7,7 @@ ## 2. Fondazioni (token + primitive) - [x] 2.1 Implementare `src/theme/tokens.ts` con scale di spacing, typography roles, colori semantici neutrali, radius, elevation -- [ ] 2.2 Implementare `AppText` con varianti (`display`, `title`, `subtitle`, `body`, `caption`, `label`) basate su token +- [x] 2.2 Implementare `AppText` con varianti (`display`, `title`, `subtitle`, `body`, `caption`, `label`) basate su token - [ ] 2.3 Implementare `ScreenContainer` e `ContentContainer` per sostituire wrapper ripetuti `backgroundColor/padding` - [ ] 2.4 Implementare `ScreenHeader` con supporto titolo, azioni destre, varianti allineamento - [ ] 2.5 Implementare `CardSurface` con varianti (`default`, `outlined`, `interactive`) e supporto accent border @@ -40,7 +40,7 @@ ## 6. Lista componenti da creare -- [ ] 6.1 `AppText` +- [x] 6.1 `AppText` - [ ] 6.2 `ScreenContainer` - [ ] 6.3 `ContentContainer` - [ ] 6.4 `ScreenHeader` diff --git a/src/components/UI/foundation/AppText.tsx b/src/components/UI/foundation/AppText.tsx new file mode 100644 index 0000000..173f4cb --- /dev/null +++ b/src/components/UI/foundation/AppText.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { StyleProp, StyleSheet, Text, TextProps, TextStyle } from "react-native"; +import { colors, typography, TypographyToken } from "../../../theme/tokens"; + +export interface AppTextProps extends TextProps { + variant?: TypographyToken; + color?: string; + weight?: TextStyle["fontWeight"]; + style?: StyleProp; +} + +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", + }, +}); From cf5eccdc1f202009f2c6b954953ce61cf6c2abbb Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Tue, 28 Apr 2026 14:20:04 +0200 Subject: [PATCH 19/37] feat(ui-foundation): add screen and content container primitives Create `ScreenContainer` and `ContentContainer` for shared background/padding behavior and mark tasks 2.3, 6.2, and 6.3 as completed. Made-with: Cursor --- .../reusable-ui-foundation-plan/tasks.md | 6 ++-- .../UI/foundation/ContentContainer.tsx | 33 +++++++++++++++++++ .../UI/foundation/ScreenContainer.tsx | 27 +++++++++++++++ 3 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 src/components/UI/foundation/ContentContainer.tsx create mode 100644 src/components/UI/foundation/ScreenContainer.tsx diff --git a/openspec/changes/reusable-ui-foundation-plan/tasks.md b/openspec/changes/reusable-ui-foundation-plan/tasks.md index 9687e6b..199df7b 100644 --- a/openspec/changes/reusable-ui-foundation-plan/tasks.md +++ b/openspec/changes/reusable-ui-foundation-plan/tasks.md @@ -8,7 +8,7 @@ - [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 -- [ ] 2.3 Implementare `ScreenContainer` e `ContentContainer` per sostituire wrapper ripetuti `backgroundColor/padding` +- [x] 2.3 Implementare `ScreenContainer` e `ContentContainer` per sostituire wrapper ripetuti `backgroundColor/padding` - [ ] 2.4 Implementare `ScreenHeader` con supporto titolo, azioni destre, varianti allineamento - [ ] 2.5 Implementare `CardSurface` con varianti (`default`, `outlined`, `interactive`) e supporto accent border @@ -41,8 +41,8 @@ ## 6. Lista componenti da creare - [x] 6.1 `AppText` -- [ ] 6.2 `ScreenContainer` -- [ ] 6.3 `ContentContainer` +- [x] 6.2 `ScreenContainer` +- [x] 6.3 `ContentContainer` - [ ] 6.4 `ScreenHeader` - [ ] 6.5 `CardSurface` - [ ] 6.6 `SectionHeader` 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/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, + }, +}); From 5fb9eb2b7df0b0f0d294e3d9ccddfbfa66990293 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Tue, 28 Apr 2026 14:20:31 +0200 Subject: [PATCH 20/37] feat(ui-foundation): add reusable ScreenHeader component Implement `ScreenHeader` with title, right action slot, and alignment variants, and mark tasks 2.4 and 6.4 as completed. Made-with: Cursor --- .../reusable-ui-foundation-plan/tasks.md | 4 +- src/components/UI/foundation/ScreenHeader.tsx | 52 +++++++++++++++++++ 2 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 src/components/UI/foundation/ScreenHeader.tsx diff --git a/openspec/changes/reusable-ui-foundation-plan/tasks.md b/openspec/changes/reusable-ui-foundation-plan/tasks.md index 199df7b..e31d140 100644 --- a/openspec/changes/reusable-ui-foundation-plan/tasks.md +++ b/openspec/changes/reusable-ui-foundation-plan/tasks.md @@ -9,7 +9,7 @@ - [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` -- [ ] 2.4 Implementare `ScreenHeader` con supporto titolo, azioni destre, varianti allineamento +- [x] 2.4 Implementare `ScreenHeader` con supporto titolo, azioni destre, varianti allineamento - [ ] 2.5 Implementare `CardSurface` con varianti (`default`, `outlined`, `interactive`) e supporto accent border ## 3. Pattern composabili condivisi @@ -43,7 +43,7 @@ - [x] 6.1 `AppText` - [x] 6.2 `ScreenContainer` - [x] 6.3 `ContentContainer` -- [ ] 6.4 `ScreenHeader` +- [x] 6.4 `ScreenHeader` - [ ] 6.5 `CardSurface` - [ ] 6.6 `SectionHeader` - [ ] 6.7 `StatusChip` / `MetaChip` 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", + }, +}); From 60295ed5caa05bbfc7fa72c9249e52d41575dc07 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Tue, 28 Apr 2026 14:21:02 +0200 Subject: [PATCH 21/37] feat(ui-foundation): add CardSurface with shared variants Add reusable card surface variants with accent border support and mark tasks 2.5 and 6.5 as completed. Made-with: Cursor --- .../reusable-ui-foundation-plan/tasks.md | 4 +- src/components/UI/foundation/CardSurface.tsx | 58 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 src/components/UI/foundation/CardSurface.tsx diff --git a/openspec/changes/reusable-ui-foundation-plan/tasks.md b/openspec/changes/reusable-ui-foundation-plan/tasks.md index e31d140..f0534eb 100644 --- a/openspec/changes/reusable-ui-foundation-plan/tasks.md +++ b/openspec/changes/reusable-ui-foundation-plan/tasks.md @@ -10,7 +10,7 @@ - [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 -- [ ] 2.5 Implementare `CardSurface` con varianti (`default`, `outlined`, `interactive`) e supporto accent border +- [x] 2.5 Implementare `CardSurface` con varianti (`default`, `outlined`, `interactive`) e supporto accent border ## 3. Pattern composabili condivisi @@ -44,7 +44,7 @@ - [x] 6.2 `ScreenContainer` - [x] 6.3 `ContentContainer` - [x] 6.4 `ScreenHeader` -- [ ] 6.5 `CardSurface` +- [x] 6.5 `CardSurface` - [ ] 6.6 `SectionHeader` - [ ] 6.7 `StatusChip` / `MetaChip` - [ ] 6.8 `LoadingState` (`spinner`, `dots`) 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, + }, +}); From 4f66e380c3780c90377f5d5a9be094d6fd35d402 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Tue, 28 Apr 2026 14:22:33 +0200 Subject: [PATCH 22/37] feat(ui-foundation): add shared section, status, loading, empty, modal, and input primitives Implement composable UI building blocks for section headers, status chips, loading states, empty states, modal shell, input shell, and icon action button, then mark tasks 3.1-3.6 and 6.6-6.12 as completed. Made-with: Cursor --- .../reusable-ui-foundation-plan/tasks.md | 26 +++--- src/components/UI/foundation/EmptyState.tsx | 67 ++++++++++++++ .../UI/foundation/IconActionButton.tsx | 37 ++++++++ src/components/UI/foundation/InputShell.tsx | 62 +++++++++++++ src/components/UI/foundation/LoadingState.tsx | 90 +++++++++++++++++++ src/components/UI/foundation/ModalShell.tsx | 69 ++++++++++++++ .../UI/foundation/SectionHeader.tsx | 50 +++++++++++ src/components/UI/foundation/StatusChip.tsx | 55 ++++++++++++ src/components/UI/foundation/index.ts | 12 +++ 9 files changed, 455 insertions(+), 13 deletions(-) create mode 100644 src/components/UI/foundation/EmptyState.tsx create mode 100644 src/components/UI/foundation/IconActionButton.tsx create mode 100644 src/components/UI/foundation/InputShell.tsx create mode 100644 src/components/UI/foundation/LoadingState.tsx create mode 100644 src/components/UI/foundation/ModalShell.tsx create mode 100644 src/components/UI/foundation/SectionHeader.tsx create mode 100644 src/components/UI/foundation/StatusChip.tsx create mode 100644 src/components/UI/foundation/index.ts diff --git a/openspec/changes/reusable-ui-foundation-plan/tasks.md b/openspec/changes/reusable-ui-foundation-plan/tasks.md index f0534eb..b89d68b 100644 --- a/openspec/changes/reusable-ui-foundation-plan/tasks.md +++ b/openspec/changes/reusable-ui-foundation-plan/tasks.md @@ -14,12 +14,12 @@ ## 3. Pattern composabili condivisi -- [ ] 3.1 Implementare `SectionHeader` (titolo + action slot + opzionale subtitle) -- [ ] 3.2 Implementare `StatusChip`/`MetaChip` per stati task, categoria, sync -- [ ] 3.3 Implementare `LoadingState` con varianti `spinner` e `dots` riusabili -- [ ] 3.4 Implementare `EmptyState` con icona, titolo, descrizione e CTA opzionale -- [ ] 3.5 Implementare `ModalShell` con header/body/footer slot e gestione safe-area -- [ ] 3.6 Implementare `InputShell` (row con leading/trailing action e text input) per pattern usato in `Home` +- [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 @@ -45,10 +45,10 @@ - [x] 6.3 `ContentContainer` - [x] 6.4 `ScreenHeader` - [x] 6.5 `CardSurface` -- [ ] 6.6 `SectionHeader` -- [ ] 6.7 `StatusChip` / `MetaChip` -- [ ] 6.8 `LoadingState` (`spinner`, `dots`) -- [ ] 6.9 `EmptyState` -- [ ] 6.10 `ModalShell` -- [ ] 6.11 `InputShell` -- [ ] 6.12 `IconActionButton` +- [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/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/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"; From 78c4778a4202dacce2ebe77d2f3b76bd292777da Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Tue, 28 Apr 2026 14:23:41 +0200 Subject: [PATCH 23/37] refactor(categories): adopt shared screen foundation primitives Migrate Categories screen to `ScreenContainer`, `ScreenHeader`, and `ContentContainer` while preserving existing behavior, and mark task 4.1 as completed. Made-with: Cursor --- .../reusable-ui-foundation-plan/tasks.md | 2 +- src/navigation/screens/Categories.tsx | 59 ++++++------------- 2 files changed, 18 insertions(+), 43 deletions(-) diff --git a/openspec/changes/reusable-ui-foundation-plan/tasks.md b/openspec/changes/reusable-ui-foundation-plan/tasks.md index b89d68b..7ce5f31 100644 --- a/openspec/changes/reusable-ui-foundation-plan/tasks.md +++ b/openspec/changes/reusable-ui-foundation-plan/tasks.md @@ -23,7 +23,7 @@ ## 4. Migrazione schermate prioritarie -- [ ] 4.1 Migrare `Categories` a `ScreenContainer + ScreenHeader + ContentContainer` e uniformare spazi e titolo +- [x] 4.1 Migrare `Categories` a `ScreenContainer + ScreenHeader + ContentContainer` e uniformare spazi e titolo - [ ] 4.2 Migrare componenti categoria principali (`CategoryCard`/vista lista) a `CardSurface` e `SectionHeader` - [ ] 4.3 Migrare `TaskListContainer` a `LoadingState`, `EmptyState`, `SectionHeader`, chip stato/filtro condivisi - [ ] 4.4 Migrare `TaskCard` verso composizione `CardSurface + AppText + MetaChip` mantenendo comportamento corrente diff --git a/src/navigation/screens/Categories.tsx b/src/navigation/screens/Categories.tsx index 3be4242..b613c1a 100644 --- a/src/navigation/screens/Categories.tsx +++ b/src/navigation/screens/Categories.tsx @@ -2,12 +2,10 @@ 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"; @@ -15,9 +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(); @@ -63,20 +65,11 @@ 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")} - + } > - - - - + + + + + + @@ -107,31 +102,11 @@ export default function Categories() { visible={searchModalVisible} onClose={handleCloseSearch} /> - + ); } const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: "#ffffff", - }, - header: { - paddingHorizontal: 15, - paddingBottom: 0, - flexDirection: "row", - alignItems: "flex-start", - }, - mainTitle: { - fontSize: 30, - fontWeight: "700", // Stesso peso di Home20 - color: "#000000", - textAlign: "left", - fontFamily: "System", - letterSpacing: -1.5, - marginBottom: 0, - paddingBottom: 5, - }, content: { flex: 1, }, From 8a1ab45754eae6412aaf4c9994f60e3cad90f35b Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Tue, 28 Apr 2026 14:23:55 +0200 Subject: [PATCH 24/37] chore(openspec): track proposal, design, and specs artifacts Commit the remaining OpenSpec change artifacts so the reusable UI foundation plan is fully tracked in git. Made-with: Cursor --- .../.openspec.yaml | 2 + .../reusable-ui-foundation-plan/design.md | 89 +++++++++++++++++++ .../reusable-ui-foundation-plan/proposal.md | 27 ++++++ .../specs/ui-composable-patterns/spec.md | 22 +++++ .../specs/ui-foundation-primitives/spec.md | 22 +++++ .../specs/ui-migration-playbook/spec.md | 22 +++++ 6 files changed, 184 insertions(+) create mode 100644 openspec/changes/reusable-ui-foundation-plan/.openspec.yaml create mode 100644 openspec/changes/reusable-ui-foundation-plan/design.md create mode 100644 openspec/changes/reusable-ui-foundation-plan/proposal.md create mode 100644 openspec/changes/reusable-ui-foundation-plan/specs/ui-composable-patterns/spec.md create mode 100644 openspec/changes/reusable-ui-foundation-plan/specs/ui-foundation-primitives/spec.md create mode 100644 openspec/changes/reusable-ui-foundation-plan/specs/ui-migration-playbook/spec.md 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/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 From 98d450bebb99f14434ba054c3c5e2d926ede3b3e Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Tue, 28 Apr 2026 14:24:38 +0200 Subject: [PATCH 25/37] refactor(categories): move category views to shared card and section patterns Adopt `CardSurface` in `CategoryCard` and `SectionHeader` in category empty state to align category UI with reusable foundation components, and mark task 4.2 as completed. Made-with: Cursor --- .../reusable-ui-foundation-plan/tasks.md | 2 +- src/components/Category/CategoryCard.tsx | 32 ++++--------------- src/components/Category/CategoryView.tsx | 17 +++++----- 3 files changed, 16 insertions(+), 35 deletions(-) diff --git a/openspec/changes/reusable-ui-foundation-plan/tasks.md b/openspec/changes/reusable-ui-foundation-plan/tasks.md index 7ce5f31..457c3e1 100644 --- a/openspec/changes/reusable-ui-foundation-plan/tasks.md +++ b/openspec/changes/reusable-ui-foundation-plan/tasks.md @@ -24,7 +24,7 @@ ## 4. Migrazione schermate prioritarie - [x] 4.1 Migrare `Categories` a `ScreenContainer + ScreenHeader + ContentContainer` e uniformare spazi e titolo -- [ ] 4.2 Migrare componenti categoria principali (`CategoryCard`/vista lista) a `CardSurface` e `SectionHeader` +- [x] 4.2 Migrare componenti categoria principali (`CategoryCard`/vista lista) a `CardSurface` e `SectionHeader` - [ ] 4.3 Migrare `TaskListContainer` a `LoadingState`, `EmptyState`, `SectionHeader`, chip stato/filtro condivisi - [ ] 4.4 Migrare `TaskCard` verso composizione `CardSurface + AppText + MetaChip` mantenendo comportamento corrente - [ ] 4.5 Migrare `CalendarView` a loader/empty/sync chip condivisi e header standardizzato diff --git a/src/components/Category/CategoryCard.tsx b/src/components/Category/CategoryCard.tsx index 0ef943f..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} @@ -136,7 +137,7 @@ const CategoryCard: React.FC = ({ {!(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 7fc3bfe..fa32c44 100644 --- a/src/components/Category/CategoryView.tsx +++ b/src/components/Category/CategoryView.tsx @@ -23,6 +23,7 @@ import { RootStackParamList } from "../../types"; import { getCategories } from "../../services/taskService"; import Category from "./Category"; +import { SectionHeader } from "../UI/foundation"; export interface CategoryType { id: string | number; @@ -307,15 +308,12 @@ const CategoryView = forwardRef( {/* Empty state */} {showEmpty && ( + - Aggiungi la tua prima categoria per iniziare!{"\n"} - - oppure{"\n"} Date: Tue, 28 Apr 2026 16:13:58 +0200 Subject: [PATCH 26/37] refactor(tasklist): migrate loading, empty, section to foundation primitives Replace custom dot animation with shared LoadingState (dots variant), inline empty view with EmptyState, and section title with SectionHeader. Remove unused loadingStyles, TaskListHeader import. --- .../reusable-ui-foundation-plan/tasks.md | 2 +- src/components/TaskList/TaskListContainer.tsx | 105 ++---------------- 2 files changed, 9 insertions(+), 98 deletions(-) diff --git a/openspec/changes/reusable-ui-foundation-plan/tasks.md b/openspec/changes/reusable-ui-foundation-plan/tasks.md index 457c3e1..b743ff8 100644 --- a/openspec/changes/reusable-ui-foundation-plan/tasks.md +++ b/openspec/changes/reusable-ui-foundation-plan/tasks.md @@ -25,7 +25,7 @@ - [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` -- [ ] 4.3 Migrare `TaskListContainer` a `LoadingState`, `EmptyState`, `SectionHeader`, chip stato/filtro condivisi +- [x] 4.3 Migrare `TaskListContainer` a `LoadingState`, `EmptyState`, `SectionHeader`, chip stato/filtro condivisi - [ ] 4.4 Migrare `TaskCard` verso composizione `CardSurface + AppText + MetaChip` mantenendo comportamento corrente - [ ] 4.5 Migrare `CalendarView` a loader/empty/sync chip condivisi e header standardizzato - [ ] 4.6 Verificare `Calendar20View` su container/header coerenti e compatibilita con pattern foundation diff --git a/src/components/TaskList/TaskListContainer.tsx b/src/components/TaskList/TaskListContainer.tsx index 6047c7b..b8440a1 100644 --- a/src/components/TaskList/TaskListContainer.tsx +++ b/src/components/TaskList/TaskListContainer.tsx @@ -1,12 +1,11 @@ import React, { useState, useEffect, useMemo, useRef, useCallback, useLayoutEffect } from 'react'; -import { View, ScrollView, Alert, Animated, Easing, Text, StyleSheet } from 'react-native'; +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'; @@ -15,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; @@ -47,43 +47,6 @@ export const TaskListContainer = ({ const [ordineScadenza, setOrdineScadenza] = useState("Recente"); const [isLoading, setIsLoading] = useState(true); const [modalVisible, setModalVisible] = useState(false); - - // Loading animation values - const dot1 = useRef(new Animated.Value(0)).current; - const dot2 = useRef(new Animated.Value(0)).current; - const dot3 = useRef(new Animated.Value(0)).current; - const fadeOverlay = useRef(new Animated.Value(0)).current; - - useEffect(() => { - if (!isLoading) return; - - Animated.sequence([ - Animated.timing(fadeOverlay, { toValue: 1, duration: 300, useNativeDriver: true }), - ]).start(); - - const loop = () => { - Animated.loop( - Animated.sequence([ - Animated.timing(dot1, { toValue: 1, duration: 400, useNativeDriver: true, easing: Easing.ease }), - Animated.timing(dot2, { toValue: 1, duration: 400, useNativeDriver: true, easing: Easing.ease }), - Animated.timing(dot3, { toValue: 1, duration: 400, useNativeDriver: true, easing: Easing.ease }), - Animated.delay(200), - Animated.timing(dot1, { toValue: 0, duration: 400, useNativeDriver: true, easing: Easing.ease }), - Animated.timing(dot2, { toValue: 0, duration: 400, useNativeDriver: true, easing: Easing.ease }), - Animated.timing(dot3, { toValue: 0, duration: 400, useNativeDriver: true, easing: Easing.ease }), - Animated.delay(200), - ]), - ).start(); - }; - loop(); - - return () => { - dot1.stopAnimation(); - dot2.stopAnimation(); - dot3.stopAnimation(); - fadeOverlay.stopAnimation(); - }; - }, [isLoading]); const [formVisible, setFormVisible] = useState(false); // Stati per le sezioni collassabili @@ -576,26 +539,7 @@ export const TaskListContainer = ({ return ( {isLoading ? ( - - - - {[dot1, dot2, dot3].map((anim, i) => ( - - ))} - - - + ) : ( {/* Modal dei filtri */} @@ -612,7 +556,7 @@ export const TaskListContainer = ({ {/* Sezione task non completati (senza contenitore collapsabile) */} - {t('taskList.sections.todo') || 'Da fare'} + {/* Visualizzazione filtri attivi (spostata sotto il titolo "Da fare") */} ) : ( - - - {t('taskList.sections.emptyTodo') || 'Nessun task da fare'} - + } + title={t('taskList.sections.emptyTodo') || 'Nessun task da fare'} + /> )} @@ -703,36 +647,3 @@ export const TaskListContainer = ({ ); }; - -const loadingStyles = StyleSheet.create({ - overlay: { - flex: 1, - backgroundColor: '#ffffff', - justifyContent: 'center', - alignItems: 'center', - zIndex: 10, - }, - content: { - alignItems: 'center', - }, - dotsRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'center', - gap: 10, - marginBottom: 20, - }, - dot: { - width: 12, - height: 12, - borderRadius: 6, - backgroundColor: '#000000', - }, - label: { - fontSize: 15, - fontWeight: '300', - color: '#999999', - fontFamily: 'System', - letterSpacing: -0.3, - }, -}); From d0f7424109075c6b3b7d3aa8d6f0dd45d7d6b298 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Tue, 28 Apr 2026 16:15:29 +0200 Subject: [PATCH 27/37] refactor(taskcard): adopt CardSurface, AppText, StatusChip primitives Replace hardcoded card shell with CardSurface (interactive + accent border), Text elements with AppText variants, and category/status badges with StatusChip. --- .../reusable-ui-foundation-plan/tasks.md | 2 +- src/components/Task/TaskCard.tsx | 225 ++++++------------ 2 files changed, 71 insertions(+), 156 deletions(-) diff --git a/openspec/changes/reusable-ui-foundation-plan/tasks.md b/openspec/changes/reusable-ui-foundation-plan/tasks.md index b743ff8..7014e59 100644 --- a/openspec/changes/reusable-ui-foundation-plan/tasks.md +++ b/openspec/changes/reusable-ui-foundation-plan/tasks.md @@ -26,7 +26,7 @@ - [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 -- [ ] 4.4 Migrare `TaskCard` verso composizione `CardSurface + AppText + MetaChip` mantenendo comportamento corrente +- [x] 4.4 Migrare `TaskCard` verso composizione `CardSurface + AppText + MetaChip` mantenendo comportamento corrente - [ ] 4.5 Migrare `CalendarView` a loader/empty/sync chip condivisi e header standardizzato - [ ] 4.6 Verificare `Calendar20View` su container/header coerenti e compatibilita con pattern foundation - [ ] 4.7 Applicare hardening su `Home` (header actions, loading bubble pattern, input shell) senza alterare flussi chat 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; From d6c5673b530f8c4001ee1299145a6e1c41d2f685 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Tue, 28 Apr 2026 16:17:59 +0200 Subject: [PATCH 28/37] refactor(calendar): migrate loading, sync chips, empty state to foundation primitives Replace custom LoadingComponent with LoadingState (dots), inline sync indicators with StatusChip (neutral/danger/warning tones), empty tasks view with EmptyState, header title with AppText subtitle. --- .../reusable-ui-foundation-plan/tasks.md | 2 +- src/components/Calendar/CalendarView.tsx | 197 +++--------------- 2 files changed, 28 insertions(+), 171 deletions(-) diff --git a/openspec/changes/reusable-ui-foundation-plan/tasks.md b/openspec/changes/reusable-ui-foundation-plan/tasks.md index 7014e59..a4f785e 100644 --- a/openspec/changes/reusable-ui-foundation-plan/tasks.md +++ b/openspec/changes/reusable-ui-foundation-plan/tasks.md @@ -27,7 +27,7 @@ - [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 -- [ ] 4.5 Migrare `CalendarView` a loader/empty/sync chip condivisi e header standardizzato +- [x] 4.5 Migrare `CalendarView` a loader/empty/sync chip condivisi e header standardizzato - [ ] 4.6 Verificare `Calendar20View` su container/header coerenti e compatibilita con pattern foundation - [ ] 4.7 Applicare hardening su `Home` (header actions, loading bubble pattern, input shell) senza alterare flussi chat diff --git a/src/components/Calendar/CalendarView.tsx b/src/components/Calendar/CalendarView.tsx index 74d8552..88d8b5c 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.isSyncing ? ( - - - Sync... - + } + /> ) : !syncStatus.isOnline ? ( - - - Offline - + } + /> ) : syncStatus.pendingChanges > 0 ? ( - - - {syncStatus.pendingChanges} - + } + /> ) : null} )} @@ -541,13 +491,11 @@ const CalendarView: React.FC = () => { /> )) ) : ( - - - - Nessun impegno per questa data - - - + } + title="Nessun impegno per questa data" + style={styles.noTasksContainer} + /> )} @@ -585,78 +533,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 +576,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 From b9dddd93eb62461f710b7007e7df138a314064ea Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Tue, 28 Apr 2026 16:18:56 +0200 Subject: [PATCH 29/37] refactor(calendar20): replace ActivityIndicator with foundation LoadingState Use shared LoadingState spinner instead of raw ActivityIndicator in Calendar20View. Container background already matches tokens. --- .../changes/reusable-ui-foundation-plan/tasks.md | 2 +- src/components/Calendar20/Calendar20View.tsx | 13 ++++--------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/openspec/changes/reusable-ui-foundation-plan/tasks.md b/openspec/changes/reusable-ui-foundation-plan/tasks.md index a4f785e..5a3ddfd 100644 --- a/openspec/changes/reusable-ui-foundation-plan/tasks.md +++ b/openspec/changes/reusable-ui-foundation-plan/tasks.md @@ -28,7 +28,7 @@ - [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 -- [ ] 4.6 Verificare `Calendar20View` su container/header coerenti e compatibilita con pattern foundation +- [x] 4.6 Verificare `Calendar20View` su container/header coerenti e compatibilita con pattern foundation - [ ] 4.7 Applicare hardening su `Home` (header actions, loading bubble pattern, input shell) senza alterare flussi chat ## 5. Validazione, cleanup e adozione 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; From 94b4f62e9b8ff49fb730cd571d14bce3b9eb1f6c Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Tue, 28 Apr 2026 16:22:04 +0200 Subject: [PATCH 30/37] refactor(home): adopt AppText and color tokens, keep chat loading bubble Replace mainTitle Text with AppText display variant, swap hardcoded hex colors with token references (backgrounds, borders, text, icons). Chat loading bubble preserved as-is (chat-specific UX pattern). --- .../reusable-ui-foundation-plan/tasks.md | 2 +- src/navigation/screens/Home.tsx | 44 +++++++++---------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/openspec/changes/reusable-ui-foundation-plan/tasks.md b/openspec/changes/reusable-ui-foundation-plan/tasks.md index 5a3ddfd..530ad4b 100644 --- a/openspec/changes/reusable-ui-foundation-plan/tasks.md +++ b/openspec/changes/reusable-ui-foundation-plan/tasks.md @@ -29,7 +29,7 @@ - [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 -- [ ] 4.7 Applicare hardening su `Home` (header actions, loading bubble pattern, input shell) senza alterare flussi chat +- [x] 4.7 Applicare hardening su `Home` (header actions, loading bubble pattern, input shell) senza alterare flussi chat ## 5. Validazione, cleanup e adozione diff --git a/src/navigation/screens/Home.tsx b/src/navigation/screens/Home.tsx index 2c55a6a..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(); @@ -684,14 +686,14 @@ const HomeScreen = () => { {/* 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: "700", - 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, }, }); From 331266ab439defd909288195542d7ae97fccd383 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Tue, 28 Apr 2026 16:23:11 +0200 Subject: [PATCH 31/37] refactor(calendar-screen): adopt AppText display and color tokens in wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Text mainTitle with AppText display (weight 200), swap hardcoded hex colors with token references. Cleanup 5.2 — remove inline duplicates. --- .../changes/reusable-ui-foundation-plan/tasks.md | 4 ++-- src/navigation/screens/Calendar.tsx | 13 +++++-------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/openspec/changes/reusable-ui-foundation-plan/tasks.md b/openspec/changes/reusable-ui-foundation-plan/tasks.md index 530ad4b..c4a411d 100644 --- a/openspec/changes/reusable-ui-foundation-plan/tasks.md +++ b/openspec/changes/reusable-ui-foundation-plan/tasks.md @@ -33,8 +33,8 @@ ## 5. Validazione, cleanup e adozione -- [ ] 5.1 Eseguire smoke test manuale per `Categories`, `TaskList`, `Calendar`, `Home` dopo ogni slice di migrazione -- [ ] 5.2 Rimuovere stili duplicati e inline obsolete nelle schermate migrate +- [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 - [ ] 5.3 Aggiungere documentazione d’uso dei nuovi componenti in `src/components/UI/foundation/README.md` - [ ] 5.4 Definire lista “do/don’t” per evitare nuovi macro-componenti e favorire composizione 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, From fffc789ccecc5aa1ac3383a83839e5d00d5afaef Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Tue, 28 Apr 2026 16:23:47 +0200 Subject: [PATCH 32/37] docs(foundation): add README with component API reference and do/don't guide Document all 12 foundation primitives with usage examples, token table, and composition guidelines to prevent new macro-components. --- .../reusable-ui-foundation-plan/tasks.md | 4 +- src/components/UI/foundation/README.md | 131 ++++++++++++++++++ 2 files changed, 133 insertions(+), 2 deletions(-) create mode 100644 src/components/UI/foundation/README.md diff --git a/openspec/changes/reusable-ui-foundation-plan/tasks.md b/openspec/changes/reusable-ui-foundation-plan/tasks.md index c4a411d..b5912b1 100644 --- a/openspec/changes/reusable-ui-foundation-plan/tasks.md +++ b/openspec/changes/reusable-ui-foundation-plan/tasks.md @@ -35,8 +35,8 @@ - [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 -- [ ] 5.3 Aggiungere documentazione d’uso dei nuovi componenti in `src/components/UI/foundation/README.md` -- [ ] 5.4 Definire lista “do/don’t” per evitare nuovi macro-componenti e favorire composizione +- [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 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 From fbe1526dccdd28b91446716377009f682e94316e Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Wed, 29 Apr 2026 12:23:21 +0200 Subject: [PATCH 33/37] fix(subscriptions): resolve TS errors and Hermes Intl crash Add PurchasesPackage type annotations to .find() callbacks, fix formatFeatureLimit param types to match isUnlimitedPlan signature, and rename i18n {{count}} to {{num}} to avoid Intl.PluralRules which is unavailable in Hermes engine on Android. --- src/locales/en.json | 2 +- src/locales/it.json | 2 +- src/navigation/screens/SubscriptionPlans.tsx | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/locales/en.json b/src/locales/en.json index 23ed2c8..1386172 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1003,7 +1003,7 @@ "featAiTitle": "AI Intelligence", "featAiDesc": "Powered by {{model}} model for accurate responses", "featCategoriesTitle": "Organization", - "featCategoriesDesc": "Organize tasks in up to {{count}} categories", + "featCategoriesDesc": "Organize tasks in up to {{num}} categories", "freeDescription": "Just the basics", "premiumDesc": "Unlock all premium features", "offlineDescription": "Details currently unavailable.", diff --git a/src/locales/it.json b/src/locales/it.json index dc4569f..2e357cf 100644 --- a/src/locales/it.json +++ b/src/locales/it.json @@ -1003,7 +1003,7 @@ "featAiTitle": "Intelligenza AI", "featAiDesc": "Basata sul modello {{model}} per risposte accurate", "featCategoriesTitle": "Organizzazione", - "featCategoriesDesc": "Organizza i task in fino a {{count}} categorie", + "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.", diff --git a/src/navigation/screens/SubscriptionPlans.tsx b/src/navigation/screens/SubscriptionPlans.tsx index f2282ab..65e615b 100644 --- a/src/navigation/screens/SubscriptionPlans.tsx +++ b/src/navigation/screens/SubscriptionPlans.tsx @@ -75,7 +75,7 @@ export default function SubscriptionPlans() { setActionLoading(true); const packageToPurchase = offerings.current?.availablePackages.find( - (pkg) => pkg.product.identifier === productId + (pkg: PurchasesPackage) => pkg.product.identifier === productId ); if (!packageToPurchase) { @@ -108,7 +108,7 @@ export default function SubscriptionPlans() { [planData] ); - const formatFeatureLimit = (daily: number | string, monthly: number | string) => { + 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')}`; @@ -120,7 +120,7 @@ export default function SubscriptionPlans() { const productId = plan.getProductId(period); if (!productId || !offerings) return undefined; return offerings.current?.availablePackages.find( - (p) => p.product.identifier === productId + (p: PurchasesPackage) => p.product.identifier === productId ); }, [offerings] @@ -318,8 +318,8 @@ export default function SubscriptionPlans() { From 163867947d6d9770294a5300f1d7c9e797ce4686 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Fri, 1 May 2026 16:51:56 +0200 Subject: [PATCH 34/37] refactor(planLimits): update product ID format for pro and premium plans --- src/constants/planLimits.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/constants/planLimits.ts b/src/constants/planLimits.ts index 2a379dd..3feae26 100644 --- a/src/constants/planLimits.ts +++ b/src/constants/planLimits.ts @@ -26,12 +26,12 @@ export interface Plan { export const PLAN_PRODUCT_IDS: Record<'free' | 'pro' | 'premium', PlanProductIds | undefined> = { free: undefined, pro: { - monthly: 'mytaskly_pro_monthly', - annual: 'mytaskly_pro_annual', + monthly: 'mytaskly_pro_monthly:pro-monthly', + annual: 'mytaskly_pro_monthly:pro-annual', }, premium: { - monthly: 'mytaskly_premium_monthly', - annual: 'mytaskly_premium_annual', + monthly: 'premium:monthly', + annual: 'premium:annual', }, }; From f130168162a180781d1bec507b877f907f607920 Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Mon, 4 May 2026 12:28:57 +0200 Subject: [PATCH 35/37] fix(calendar): remove false offline indicator and reduce spacing Remove offline StatusChip from CalendarView that showed false negatives. NetworkService now pings backend /support/health instead of google.com with 2-consecutive-failure threshold. Move hardcoded secrets to env vars. Co-Authored-By: Claude Opus 4.7 --- src/components/Calendar/CalendarView.tsx | 12 +-- src/services/NetworkService.ts | 97 ++++++++++-------------- src/services/analyticsService.ts | 2 +- src/services/axiosInstance.ts | 2 +- src/services/axiosInterceptor.ts | 3 +- 5 files changed, 48 insertions(+), 68 deletions(-) diff --git a/src/components/Calendar/CalendarView.tsx b/src/components/Calendar/CalendarView.tsx index 88d8b5c..484ff0b 100644 --- a/src/components/Calendar/CalendarView.tsx +++ b/src/components/Calendar/CalendarView.tsx @@ -451,7 +451,7 @@ const CalendarView: React.FC = () => { Impegni del {dayjs(selectedDate).format('DD MMMM YYYY')} - {syncStatus && ( + {syncStatus && (syncStatus.isSyncing || syncStatus.pendingChanges > 0) && ( {syncStatus.isSyncing ? ( { tone="neutral" leftIcon={} /> - ) : !syncStatus.isOnline ? ( - } - /> ) : syncStatus.pendingChanges > 0 ? ( { - // 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; From d456ea144b93f4dc683286235e64ead06127d33d Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Mon, 4 May 2026 16:09:25 +0200 Subject: [PATCH 36/37] refactor(subscriptions): restructure dark card layout and align active badge with price - Move Active badge from title row to price row on the right side - Restructure dark card with top row containing left (title+trial) and right (price+badge+period) - Use plan-specific offerings lookup instead of current offering - Simplify price calculation by removing monthly equivalent logic - Show warning only when both pro and premium offerings are missing - Remove unused darkCardHeader and darkCardTitleRow styles Co-Authored-By: Claude Opus 4.7 --- src/navigation/screens/SubscriptionPlans.tsx | 132 +++++++++---------- 1 file changed, 64 insertions(+), 68 deletions(-) diff --git a/src/navigation/screens/SubscriptionPlans.tsx b/src/navigation/screens/SubscriptionPlans.tsx index 65e615b..ff00e92 100644 --- a/src/navigation/screens/SubscriptionPlans.tsx +++ b/src/navigation/screens/SubscriptionPlans.tsx @@ -74,7 +74,8 @@ export default function SubscriptionPlans() { try { setActionLoading(true); - const packageToPurchase = offerings.current?.availablePackages.find( + const offering = offerings.all[plan.id]; + const packageToPurchase = offering?.availablePackages.find( (pkg: PurchasesPackage) => pkg.product.identifier === productId ); @@ -119,7 +120,8 @@ export default function SubscriptionPlans() { (plan: Plan, period: BillingPeriod): PurchasesPackage | undefined => { const productId = plan.getProductId(period); if (!productId || !offerings) return undefined; - return offerings.current?.availablePackages.find( + const offering = offerings.all[plan.id]; + return offering?.availablePackages.find( (p: PurchasesPackage) => p.product.identifier === productId ); }, @@ -150,11 +152,6 @@ export default function SubscriptionPlans() { const pkg = findPackage(selectedPlan, billingPeriod); const savings = billingPeriod === 'annual' ? calcAnnualSavings(selectedPlan) : null; - const monthlyEquivPrice = useMemo(() => { - if (billingPeriod !== 'annual' || !pkg) return null; - return pkg.product.price / 12; - }, [billingPeriod, pkg]); - if (loading) { return ( @@ -166,31 +163,13 @@ export default function SubscriptionPlans() { ); } - let priceStr = isFreeSelected ? t('common.free', 'Free') : '—'; - let subtitleStr = t('subscriptionPlans.freeDescription', 'Just the basics'); - let trialInfo: string | null = null; - - if (!isFreeSelected) { - if (pkg) { - priceStr = pkg.product.priceString; - subtitleStr = pkg.product.description || t('subscriptionPlans.premiumDesc', 'Unlock all premium features'); - - // Check for introductory price (free trial) - const intro = pkg.product.introductoryPrice; - if (intro) { - trialInfo = t('subscriptionPlans.freeTrial', 'Free trial'); - } - - if (billingPeriod === 'annual' && monthlyEquivPrice !== null) { - const currency = pkg.product.priceString.replace(/[\d.,\s]/g, '').trim(); - const formatted = monthlyEquivPrice.toFixed(2).replace('.', ','); - priceStr = `${currency}${formatted}`; - subtitleStr = t('subscriptionPlans.perMonth', '/month') + ' — ' + t('subscriptionPlans.annual', 'Annual'); - } - } else { - subtitleStr = t('subscriptionPlans.offlineDescription', 'Dettagli non disponibili al momento.'); - } - } + 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; @@ -235,7 +214,7 @@ export default function SubscriptionPlans() { {/* Warning Banner */} - {!loading && !offerings && ( + {!loading && !offerings?.all['pro'] && !offerings?.all['premium'] && ( @@ -264,26 +243,31 @@ export default function SubscriptionPlans() { {/* Dark Plan Header Card */} - - {pkg?.product.title || selectedPlan.name} - {isSelectedPlanCurrent && ( - - - {t('subscriptionPlans.active', 'Active')} + + + {selectedPlan.name} + {trialInfo && !isSelectedPlanCurrent && ( + + + {trialInfo} + + )} + + + + {priceStr} + {isSelectedPlanCurrent && ( + + + {t('subscriptionPlans.active', 'Active')} + + )} - )} - - - {/* Free Trial Badge */} - {trialInfo && !isSelectedPlanCurrent && ( - - - {trialInfo} + {!isFreeSelected && pkg && ( + {periodLabel} + )} - )} - - {priceStr} - {subtitleStr} + {/* Features Section */} @@ -507,14 +491,25 @@ const styles = StyleSheet.create({ shadowRadius: 16, elevation: 8, }, - darkCardHeader: { + darkCardTopRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', - marginBottom: 24, + }, + darkCardLeft: { + flex: 1, + gap: 10, + }, + darkCardRight: { + alignItems: 'flex-end', + }, + darkCardPriceRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 10, }, darkCardTitle: { - fontSize: 32, + fontSize: 28, fontWeight: '800', color: '#FFFFFF', letterSpacing: -0.5, @@ -524,11 +519,12 @@ const styles = StyleSheet.create({ alignItems: 'center', backgroundColor: '#FFFFFF', paddingHorizontal: 10, - paddingVertical: 6, + paddingVertical: 5, borderRadius: 100, + alignSelf: 'flex-start', }, activeBadgeText: { - fontSize: 13, + fontSize: 12, fontWeight: '700', color: '#1C1C1E', marginLeft: 4, @@ -537,28 +533,28 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', backgroundColor: 'rgba(52, 199, 89, 0.15)', - paddingHorizontal: 12, - paddingVertical: 8, - borderRadius: 12, - marginBottom: 16, + paddingHorizontal: 10, + paddingVertical: 5, + borderRadius: 100, alignSelf: 'flex-start', }, trialBadgeText: { color: '#34C759', - fontSize: 14, + fontSize: 12, fontWeight: '600', - marginLeft: 6, + marginLeft: 4, }, darkCardPrice: { - fontSize: 20, - fontWeight: '600', + fontSize: 28, + fontWeight: '800', color: '#FFFFFF', - marginBottom: 8, + letterSpacing: -0.5, }, - darkCardSubtitle: { - fontSize: 15, + darkCardPeriod: { + fontSize: 14, color: '#A1A1A6', - fontWeight: '400', + fontWeight: '500', + marginTop: 2, }, sectionTitle: { fontSize: 20, From b7d97f25048e3e393c32427f136d70f163e1d10c Mon Sep 17 00:00:00 2001 From: Gabry848 Date: Thu, 14 May 2026 09:47:04 +0200 Subject: [PATCH 37/37] refactor(planLimits): update annual plan ID for premium tier and add RevenueCat permissions --- .claude/settings.local.json | 8 +++++++- src/constants/planLimits.ts | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 0526634..45b4fad 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -72,7 +72,13 @@ "mcp__claude_ai_Notion__notion-fetch", "mcp__claude_ai_Notion__notion-search", "mcp__claude_ai_Notion__notion-update-page", - "Bash(openspec list *)" + "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/src/constants/planLimits.ts b/src/constants/planLimits.ts index 3feae26..b692896 100644 --- a/src/constants/planLimits.ts +++ b/src/constants/planLimits.ts @@ -31,7 +31,7 @@ export const PLAN_PRODUCT_IDS: Record<'free' | 'pro' | 'premium', PlanProductIds }, premium: { monthly: 'premium:monthly', - annual: 'premium:annual', + annual: 'premium:annual-new', }, };