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.OnPush obligatorio
- Implementar gestos touch-friendly (min 44x44px)
- Considerar keyboard virtual (bottom padding dinámico)
- Optimizar para 3G/4G (lazy loading agresivo)
typescript
1@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:
typescript
1// ✅ 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
typescript
1// 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
typescript
1// 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
typescript
1// 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}
typescript
1@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
typescript
1// 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
typescript
1// 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
typescript
1// 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
typescript
1// 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}
typescript
1@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
typescript
1// 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
typescript
1import { 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
typescript
1describe('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:
typescript
1// 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
typescript
1// 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
bash
1#!/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
bash
1#!/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:
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+