Angular + Capacitor Mobile Patterns
Patrones arquitectónicos especializados para aplicaciones móviles híbridas usando Angular Standalone Components, Capacitor 6, y estrategias offline-first.
Cuándo Usar Esta Skill
- Crear componentes móviles optimizados para touch
- Implementar sincronización offline-first con Supabase
- Integrar plugins de Capacitor (Notifications, Storage, Camera)
- Configurar animaciones GSAP para gamificación
- Estructurar features con NgRx Signals
Principios Fundamentales
1. Mobile-First Architecture
Todos los componentes deben:
- Usar
ChangeDetectionStrategy.OnPushobligatorio - Implementar gestos touch-friendly (min 44x44px)
- Considerar keyboard virtual (bottom padding dinámico)
- Optimizar para 3G/4G (lazy loading agresivo)
typescript1@Component({ 2 selector: 'app-block-card', 3 standalone: true, 4 changeDetection: ChangeDetectionStrategy.OnPush, 5 host: { 6 '[style.min-height.px]': '88', // 2x touch target 7 '[class.ios]': 'platform.is("ios")', 8 '[class.android]': 'platform.is("android")' 9 } 10}) 11export class BlockCardComponent {}
2. Offline-First Data Flow
┌─────────────────────────────────────────────┐
│ User Action (Complete Block) │
└───────────────┬─────────────────────────────┘
│
▼
┌───────────────────────────────────────────┐
│ 1. Write to Local SQLite FIRST │
│ (Instant UI update via signals) │
└───────────────┬───────────────────────────┘
│
▼
┌───────────────────────────────────────────┐
│ 2. Queue Sync Operation │
│ (Background service) │
└───────────────┬───────────────────────────┘
│
▼
┌──────┴───────┐
│ Online? │
└──┬────────┬──┘
YES NO
│ │
│ └─> Store in sync_queue
│ (retry on reconnect)
▼
┌───────────────────────────────────────────┐
│ 3. Push to Supabase │
│ (Last-write-wins conflict resolution) │
└───────────────┬───────────────────────────┘
│
▼
┌───────────────────────────────────────────┐
│ 4. Update Local with server response │
│ (Reconcile timestamps) │
└───────────────────────────────────────────┘
3. NgRx Signals State Management
NUNCA usar NgRx Store clásico. Siempre Signals:
typescript1// ✅ CORRECTO: Signal Store con computeds 2import { signalStore, withState, withComputed, withMethods } from '@ngrx/signals'; 3 4interface BlocksState { 5 blocks: DailyBlock[]; 6 selectedDate: string; 7 loading: boolean; 8} 9 10export const BlocksStore = signalStore( 11 { providedIn: 'root' }, 12 withState<BlocksState>({ 13 blocks: [], 14 selectedDate: new Date().toISOString().split('T')[0], 15 loading: false 16 }), 17 18 withComputed(({ blocks, selectedDate }) => ({ 19 todayBlocks: computed(() => 20 blocks().filter(b => b.date === selectedDate()) 21 ), 22 completedCount: computed(() => 23 blocks().filter(b => b.status === 'completed').length 24 ), 25 completionRate: computed(() => { 26 const total = blocks().length; 27 const completed = blocks().filter(b => b.status === 'completed').length; 28 return total > 0 ? (completed / total) * 100 : 0; 29 }) 30 })), 31 32 withMethods((store, blocksRepo = inject(BlocksRepository)) => ({ 33 async loadBlocks(userId: string, date: string) { 34 patchState(store, { loading: true }); 35 try { 36 const blocks = await blocksRepo.getDailyBlocks(userId, date); 37 patchState(store, { blocks, selectedDate: date, loading: false }); 38 } catch (error) { 39 console.error('Failed to load blocks', error); 40 patchState(store, { loading: false }); 41 } 42 }, 43 44 async completeBlock(blockId: string, note?: string) { 45 // Optimistic update 46 const updatedBlocks = store.blocks().map(b => 47 b.id === blockId 48 ? { ...b, status: 'completed' as const, completion_note: note } 49 : b 50 ); 51 patchState(store, { blocks: updatedBlocks }); 52 53 // Background sync 54 try { 55 await blocksRepo.completeBlock(blockId, note); 56 } catch (error) { 57 // Rollback on failure 58 patchState(store, { blocks: store.blocks() }); 59 throw error; 60 } 61 } 62 })) 63);
Capacitor Integration Patterns
Plugin Initialization
typescript1// src/app/core/capacitor/capacitor.service.ts 2import { Injectable, inject } from '@angular/core'; 3import { Platform } from '@angular/cdk/platform'; 4import { App } from '@capacitor/app'; 5import { StatusBar, Style } from '@capacitor/status-bar'; 6import { SplashScreen } from '@capacitor/splash-screen'; 7 8@Injectable({ providedIn: 'root' }) 9export class CapacitorService { 10 private platform = inject(Platform); 11 12 async initialize() { 13 if (!this.platform.isBrowser) { 14 await this.setupStatusBar(); 15 await this.setupAppListeners(); 16 await SplashScreen.hide(); 17 } 18 } 19 20 private async setupStatusBar() { 21 if (this.platform.IOS) { 22 await StatusBar.setStyle({ style: Style.Dark }); 23 } else if (this.platform.ANDROID) { 24 await StatusBar.setBackgroundColor({ color: '#1a202c' }); 25 } 26 } 27 28 private async setupAppListeners() { 29 App.addListener('appStateChange', ({ isActive }) => { 30 if (isActive) { 31 // Resume sync cuando app vuelve a foreground 32 inject(SyncService).resumeSync(); 33 } 34 }); 35 36 App.addListener('backButton', ({ canGoBack }) => { 37 if (!canGoBack) { 38 App.exitApp(); 39 } 40 }); 41 } 42}
Local Notifications Pattern
typescript1// src/app/core/notifications/notification.service.ts 2import { LocalNotifications, ScheduleOptions } from '@capacitor/local-notifications'; 3import { Haptics, ImpactStyle } from '@capacitor/haptics'; 4 5@Injectable({ providedIn: 'root' }) 6export class NotificationService { 7 private hasPermissions = signal(false); 8 9 async init() { 10 const result = await LocalNotifications.requestPermissions(); 11 this.hasPermissions.set(result.display === 'granted'); 12 } 13 14 async scheduleBlockReminder(block: DailyBlock, minutesBefore: number = 5) { 15 if (!this.hasPermissions()) return; 16 17 const reminderTime = new Date(block.start_time); 18 reminderTime.setMinutes(reminderTime.getMinutes() - minutesBefore); 19 20 await LocalNotifications.schedule({ 21 notifications: [{ 22 id: this.generateNotificationId(block.id), 23 title: `Próximo: ${block.name}`, 24 body: `Comienza en ${minutesBefore} minutos`, 25 schedule: { at: reminderTime }, 26 actionTypeId: 'BLOCK_REMINDER', 27 extra: { blockId: block.id, blockName: block.name } 28 }] 29 }); 30 } 31 32 // Cancelar todas las notificaciones de un día específico 33 async cancelDayNotifications(date: string) { 34 const pending = await LocalNotifications.getPending(); 35 const idsToCancel = pending.notifications 36 .filter(n => n.extra?.date === date) 37 .map(n => n.id); 38 39 if (idsToCancel.length > 0) { 40 await LocalNotifications.cancel({ notifications: idsToCancel.map(id => ({ id })) }); 41 } 42 } 43 44 // Haptic feedback para confirmaciones 45 async triggerSuccess() { 46 await Haptics.impact({ style: ImpactStyle.Medium }); 47 } 48 49 async triggerError() { 50 await Haptics.notification({ type: NotificationType.Error }); 51 } 52 53 private generateNotificationId(blockId: string): number { 54 // Convertir UUID a número único (primeros 8 chars en hex) 55 return parseInt(blockId.slice(0, 8), 16); 56 } 57}
PrimeNG Mobile Optimizations
Touch-Optimized Dialog
typescript1// Wrapper component para dialogs móviles 2@Component({ 3 selector: 'app-mobile-dialog', 4 standalone: true, 5 imports: [DialogModule], 6 template: ` 7 <p-dialog 8 [(visible)]="visible" 9 [modal]="true" 10 [dismissableMask]="true" 11 [blockScroll]="true" 12 [styleClass]="'mobile-dialog'" 13 [position]="position()" 14 [breakpoints]="{ '960px': '90vw', '640px': '100vw' }" 15 > 16 <ng-content></ng-content> 17 </p-dialog> 18 `, 19 styles: [` 20 :host ::ng-deep .mobile-dialog { 21 .p-dialog-content { 22 padding: var(--spacing-lg); 23 max-height: 70vh; 24 overflow-y: auto; 25 -webkit-overflow-scrolling: touch; 26 } 27 28 // iOS safe area 29 @supports (padding: env(safe-area-inset-bottom)) { 30 .p-dialog-footer { 31 padding-bottom: calc(var(--spacing-md) + env(safe-area-inset-bottom)); 32 } 33 } 34 } 35 `] 36}) 37export class MobileDialogComponent { 38 visible = model<boolean>(false); 39 40 private platform = inject(Platform); 41 42 position = computed(() => 43 this.platform.IOS ? 'bottom' : 'center' 44 ); 45}
Infinite Scroll con DataView
typescript1@Component({ 2 selector: 'app-habit-history', 3 standalone: true, 4 imports: [DataViewModule, ScrollerModule], 5 template: ` 6 <p-dataView 7 [value]="visibleItems()" 8 [layout]="'list'" 9 [lazy]="true" 10 (onLazyLoad)="loadMore($event)" 11 > 12 <ng-template let-item pTemplate="listItem"> 13 <app-habit-card [habit]="item" /> 14 </ng-template> 15 </p-dataView> 16 ` 17}) 18export class HabitHistoryComponent { 19 private habitsStore = inject(HabitsStore); 20 21 visibleItems = computed(() => 22 this.habitsStore.habits().slice(0, this.pageSize() * this.currentPage()) 23 ); 24 25 private pageSize = signal(20); 26 private currentPage = signal(1); 27 28 loadMore(event: any) { 29 this.currentPage.update(p => p + 1); 30 } 31}
GSAP Animation Patterns
Streak Celebration Animation
typescript1// src/app/shared/animations/streak.animations.ts 2import gsap from 'gsap'; 3 4export class StreakAnimations { 5 static playStreakComplete(element: HTMLElement, days: number) { 6 const tl = gsap.timeline(); 7 8 // Scale + Rotation 9 tl.to(element, { 10 scale: 1.3, 11 rotation: 360, 12 duration: 0.6, 13 ease: 'back.out(2)', 14 onStart: () => { 15 element.classList.add('celebrating'); 16 } 17 }); 18 19 // Bounce back 20 tl.to(element, { 21 scale: 1, 22 duration: 0.3, 23 ease: 'elastic.out(1, 0.5)' 24 }); 25 26 // Milestone confetti 27 if ([7, 30, 100].includes(days)) { 28 this.playConfetti(element.parentElement!); 29 } 30 } 31 32 static playStreakLost(element: HTMLElement) { 33 const tl = gsap.timeline(); 34 35 // Shake violently 36 tl.to(element, { 37 x: -10, 38 duration: 0.05, 39 repeat: 5, 40 yoyo: true 41 }); 42 43 // Fade out and shrink 44 tl.to(element, { 45 opacity: 0, 46 scale: 0.5, 47 duration: 0.4, 48 ease: 'power2.in', 49 onComplete: () => { 50 gsap.set(element, { opacity: 1, scale: 1, x: 0 }); 51 } 52 }); 53 } 54 55 private static playConfetti(container: HTMLElement) { 56 // Crear partículas 57 const colors = ['#6366f1', '#10b981', '#f59e0b', '#ef4444']; 58 const particleCount = 30; 59 60 for (let i = 0; i < particleCount; i++) { 61 const particle = document.createElement('div'); 62 particle.className = 'confetti-particle'; 63 particle.style.backgroundColor = colors[Math.floor(Math.random() * colors.length)]; 64 container.appendChild(particle); 65 66 gsap.fromTo(particle, 67 { 68 x: 0, 69 y: 0, 70 opacity: 1, 71 scale: 1 72 }, 73 { 74 x: (Math.random() - 0.5) * 300, 75 y: -200 + Math.random() * 100, 76 opacity: 0, 77 scale: 0, 78 duration: 1.5, 79 ease: 'power2.out', 80 onComplete: () => particle.remove() 81 } 82 ); 83 } 84 } 85}
Page Transition Animation
typescript1// src/app/core/animations/route-animations.ts 2import { trigger, transition, style, animate, query } from '@angular/animations'; 3 4export const slideInAnimation = trigger('routeAnimations', [ 5 transition('* <=> *', [ 6 query(':enter, :leave', [ 7 style({ 8 position: 'absolute', 9 width: '100%', 10 transform: 'translateX(0)', 11 opacity: 1 12 }) 13 ], { optional: true }), 14 15 query(':enter', [ 16 style({ transform: 'translateX(100%)', opacity: 0 }) 17 ], { optional: true }), 18 19 query(':leave', [ 20 animate('300ms ease-out', style({ 21 transform: 'translateX(-100%)', 22 opacity: 0 23 })) 24 ], { optional: true }), 25 26 query(':enter', [ 27 animate('300ms 150ms ease-out', style({ 28 transform: 'translateX(0)', 29 opacity: 1 30 })) 31 ], { optional: true }) 32 ]) 33]);
Offline Sync Patterns
SQLite Repository
typescript1// src/app/core/database/sqlite.service.ts 2import { CapacitorSQLite, SQLiteConnection, SQLiteDBConnection } from '@capacitor-community/sqlite'; 3 4@Injectable({ providedIn: 'root' }) 5export class SQLiteService { 6 private sqlite: SQLiteConnection; 7 private db!: SQLiteDBConnection; 8 9 async init() { 10 this.sqlite = new SQLiteConnection(CapacitorSQLite); 11 12 this.db = await this.sqlite.createConnection( 13 'lifeblocks', 14 false, 15 'no-encryption', 16 1, 17 false 18 ); 19 20 await this.db.open(); 21 await this.createTables(); 22 } 23 24 private async createTables() { 25 const schema = ` 26 CREATE TABLE IF NOT EXISTS daily_blocks ( 27 id TEXT PRIMARY KEY, 28 user_id TEXT NOT NULL, 29 date TEXT NOT NULL, 30 name TEXT NOT NULL, 31 start_time TEXT NOT NULL, 32 end_time TEXT NOT NULL, 33 status TEXT DEFAULT 'pending', 34 completion_note TEXT, 35 completed_at TEXT, 36 synced INTEGER DEFAULT 0, 37 updated_at TEXT DEFAULT CURRENT_TIMESTAMP 38 ); 39 40 CREATE INDEX IF NOT EXISTS idx_blocks_date ON daily_blocks(date); 41 CREATE INDEX IF NOT EXISTS idx_blocks_sync ON daily_blocks(synced); 42 43 CREATE TABLE IF NOT EXISTS sync_queue ( 44 id INTEGER PRIMARY KEY AUTOINCREMENT, 45 operation TEXT NOT NULL, 46 table_name TEXT NOT NULL, 47 record_id TEXT NOT NULL, 48 payload TEXT NOT NULL, 49 created_at TEXT DEFAULT CURRENT_TIMESTAMP 50 ); 51 `; 52 53 await this.db.execute(schema); 54 } 55 56 async getUnsyncedBlocks(): Promise<DailyBlock[]> { 57 const result = await this.db.query( 58 'SELECT * FROM daily_blocks WHERE synced = 0' 59 ); 60 return result.values || []; 61 } 62 63 async markAsSynced(blockId: string) { 64 await this.db.run( 65 'UPDATE daily_blocks SET synced = 1 WHERE id = ?', 66 [blockId] 67 ); 68 } 69}
Background Sync Service
typescript1// src/app/core/sync/background-sync.service.ts 2@Injectable({ providedIn: 'root' }) 3export class BackgroundSyncService { 4 private sqlite = inject(SQLiteService); 5 private supabase = inject(SupabaseService); 6 private network = inject(NetworkService); 7 8 private syncInterval$ = interval(30000); // 30 segundos 9 10 startAutoSync() { 11 this.syncInterval$ 12 .pipe( 13 filter(() => this.network.isOnline()), 14 switchMap(() => this.syncPendingChanges()), 15 catchError(error => { 16 console.error('Sync failed', error); 17 return of(null); 18 }) 19 ) 20 .subscribe(); 21 } 22 23 async syncPendingChanges() { 24 const unsynced = await this.sqlite.getUnsyncedBlocks(); 25 26 for (const block of unsynced) { 27 try { 28 await this.supabase 29 .from('daily_blocks') 30 .upsert(block, { onConflict: 'id' }); 31 32 await this.sqlite.markAsSynced(block.id); 33 } catch (error) { 34 console.error(`Failed to sync block ${block.id}`, error); 35 // Continuar con el siguiente 36 } 37 } 38 } 39}
Performance Optimization
Virtual Scrolling for Long Lists
typescript1@Component({ 2 selector: 'app-inventory-list', 3 standalone: true, 4 imports: [ScrollingModule, VirtualScrollerModule], 5 template: ` 6 <cdk-virtual-scroll-viewport itemSize="72" class="viewport"> 7 <app-inventory-item 8 *cdkVirtualFor="let item of items(); trackBy: trackById" 9 [item]="item" 10 /> 11 </cdk-virtual-scroll-viewport> 12 ` 13}) 14export class InventoryListComponent { 15 items = input.required<InventoryItem[]>(); 16 17 trackById(index: number, item: InventoryItem) { 18 return item.id; 19 } 20}
Image Loading Strategy
typescript1// Directiva para lazy loading de imágenes 2@Directive({ 3 selector: 'img[appLazyLoad]', 4 standalone: true 5}) 6export class LazyLoadDirective implements OnInit { 7 @Input() src!: string; 8 private el = inject(ElementRef<HTMLImageElement>); 9 10 ngOnInit() { 11 if ('IntersectionObserver' in window) { 12 const observer = new IntersectionObserver((entries) => { 13 entries.forEach(entry => { 14 if (entry.isIntersecting) { 15 this.loadImage(); 16 observer.disconnect(); 17 } 18 }); 19 }); 20 21 observer.observe(this.el.nativeElement); 22 } else { 23 this.loadImage(); 24 } 25 } 26 27 private loadImage() { 28 this.el.nativeElement.src = this.src; 29 } 30}
Testing Patterns
Component Testing with Signals
typescript1import { ComponentFixture, TestBed } from '@angular/core/testing'; 2import { signal } from '@angular/core'; 3 4describe('BlockCardComponent', () => { 5 let component: BlockCardComponent; 6 let fixture: ComponentFixture<BlockCardComponent>; 7 8 beforeEach(async () => { 9 await TestBed.configureTestingModule({ 10 imports: [BlockCardComponent] 11 }).compileComponents(); 12 13 fixture = TestBed.createComponent(BlockCardComponent); 14 component = fixture.componentInstance; 15 }); 16 17 it('should update completion status when confirmed', async () => { 18 const mockBlock = { 19 id: '1', 20 name: 'Deep Work', 21 status: 'pending' as const 22 }; 23 24 fixture.componentRef.setInput('block', mockBlock); 25 fixture.detectChanges(); 26 27 // Simular confirmación 28 await component.confirmCompletion('Terminé el módulo de auth'); 29 30 expect(component.block().status).toBe('completed'); 31 }); 32});
Service Testing with Mocks
typescript1describe('BlocksRepository', () => { 2 let repo: BlocksRepository; 3 let mockSupabase: jasmine.SpyObj<SupabaseClient>; 4 5 beforeEach(() => { 6 mockSupabase = jasmine.createSpyObj('SupabaseClient', ['from']); 7 8 TestBed.configureTestingModule({ 9 providers: [ 10 BlocksRepository, 11 { provide: SupabaseService, useValue: { client: mockSupabase } } 12 ] 13 }); 14 15 repo = TestBed.inject(BlocksRepository); 16 }); 17 18 it('should fetch daily blocks', async () => { 19 const mockData = [{ id: '1', name: 'Gym' }]; 20 mockSupabase.from.and.returnValue({ 21 select: () => ({ 22 eq: () => ({ 23 eq: () => Promise.resolve({ data: mockData, error: null }) 24 }) 25 }) 26 } as any); 27 28 const result = await repo.getDailyBlocks('user-1', '2025-01-30'); 29 30 expect(result).toEqual(mockData); 31 }); 32});
Troubleshooting Common Issues
Issue: Notificaciones no aparecen en iOS
Causa: Permisos no solicitados correctamente
Solución:
typescript1// Solicitar permisos DESPUÉS de que el usuario interactúe 2async requestNotificationPermissions() { 3 const result = await LocalNotifications.requestPermissions(); 4 5 if (result.display !== 'granted') { 6 // Mostrar dialog explicando por qué son necesarias 7 this.showPermissionsExplanation(); 8 } 9}
Issue: App se congela en sincronización
Causa: Operaciones síncronas en main thread
Solución: Usar Web Workers
typescript1// sync.worker.ts 2addEventListener('message', async (e) => { 3 const { blocks } = e.data; 4 5 // Procesamiento pesado aquí 6 const processed = await heavySync(blocks); 7 8 postMessage({ result: processed }); 9});
Issue: Memoria creciente en listas largas
Causa: No usar virtual scrolling
Solución: Implementar cdk-virtual-scroll-viewport (ver sección Performance)
Scripts de Automatización
Generar Componente Mobile-Ready
bash1#!/bin/bash 2# scripts/generate-mobile-component.sh 3 4COMPONENT_NAME=$1 5 6ng generate component "features/${COMPONENT_NAME}" \ 7 --standalone=true \ 8 --change-detection=OnPush \ 9 --skip-tests=false \ 10 --style=scss 11 12echo "✅ Componente mobile-ready creado en features/${COMPONENT_NAME}"
Sincronizar Tipos de Supabase
bash1#!/bin/bash 2# scripts/sync-supabase-types.sh 3 4npx supabase gen types typescript \ 5 --project-id $SUPABASE_PROJECT_ID \ 6 > src/app/core/supabase/database.types.ts 7 8echo "✅ Tipos de Supabase actualizados"
Checklist de Code Review
Antes de merge, verificar:
- Todos los componentes usan
ChangeDetectionStrategy.OnPush - State management usa NgRx Signals (no NgRx Store)
- Sincronización escribe primero a SQLite, luego a Supabase
- Notificaciones tienen haptic feedback
- Touch targets mínimo 44x44px
- Animaciones GSAP optimizadas (
willChangeusado correctamente) - Tests cubren casos offline y online
- Safe area insets considerados para iOS
Related skills: angular-primeng, angular-best-practices-primeng (PrimeNG); angular (Signals, Standalone).
Versión: 1.0.0
Última actualización: 2025-01-30
Compatibilidad: Angular 18+, Capacitor 6+, PrimeNG 18+