@sincpro/mobile 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -1,91 +1,875 @@
|
|
|
1
1
|
# @sincpro/mobile
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Enterprise mobile application framework for React Native / Expo** — built on hexagonal architecture, domain-driven design, and offline-first infrastructure.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Enterprise mobile development has always been hard. Applications need to work without internet, recover from failures gracefully, sync data when connectivity returns, manage authentication state, handle background tasks, print documents, and do all of this across iOS and Android with a single codebase. Each project that solves these problems from scratch solves them differently — and then someone has to maintain those differences forever.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
`@sincpro/mobile` is Sincpro's answer to that problem. This framework encodes proven enterprise patterns once — event queues, dead-letter queues, domain event sourcing, offline-first storage, cron workers, adapter ports — and every app built on it inherits those capabilities by default.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
**Sincpro maintains this.** The source is open and anyone can read it. What Sincpro provides is the commitment behind it: stable contracts, long-term API support, updates as platforms evolve, and people accountable for the behaviors your business depends on. This is not a project that gets abandoned. It is infrastructure for the new era of enterprise mobile development, and Sincpro intends to maintain it for the teams and clients that build on top of it.
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
- **Module-driven + inversión de dependencia.** El core no conoce los negocios. Cada dominio es un `DomainModule` que la app registra; el `orchestrator` alimenta cola, repos, migraciones, cron y temas desde los módulos registrados. Agregar/mover un dominio no toca el core.
|
|
13
|
-
- **Offline-first.** SQLite + cola de eventos persistente (con DLQ y reintentos) + sincronización; la app funciona sin red y reconcilia al volver.
|
|
14
|
-
- **Hexagonal.** El dominio define puertos (`IPrinterDriver`, `IRepository`, `IRemoteClient`); los adapters concretos viven afuera y se inyectan. El core es agnóstico de hardware y de backend.
|
|
15
|
-
- **Frontera real verificable.** Capas con dirección estricta `apps → mobile-odoo → mobile → mobile-ui`; el design system no sabe de negocio; los enums/eventos tienen un punto único de export.
|
|
11
|
+
> **AI agent?** Read [`AGENTS.md`](AGENTS.md) first — ecosystem orientation, patterns, and traps. Known runtime issues live in [`docs/GOTCHAS.md`](docs/GOTCHAS.md).
|
|
16
12
|
|
|
17
|
-
|
|
13
|
+
---
|
|
18
14
|
|
|
19
|
-
|
|
20
|
-
| ---------------------- | ------------------------------------------------------------------------------------------- |
|
|
21
|
-
| `@sincpro/mobile` | Core: infra, patrones DDD, `framework/` (DomainModule, createApp, orchestrator), `AppShell` |
|
|
22
|
-
| `@sincpro/mobile-ui` | Design system standalone (sin router, sin dominios) |
|
|
23
|
-
| `@sincpro/mobile-odoo` | Integración Odoo **opcional** (OdooClient, auth, server, partner) |
|
|
24
|
-
| `sincpro-mobile-<app>` | Apps de negocio (tickets, distribution) que componen lo anterior |
|
|
15
|
+
## ⚡ Quick Start
|
|
25
16
|
|
|
26
|
-
|
|
17
|
+
Here is the minimum to get a working enterprise app running:
|
|
27
18
|
|
|
28
19
|
```bash
|
|
29
20
|
npx expo install @sincpro/mobile @sincpro/mobile-ui
|
|
30
21
|
```
|
|
31
22
|
|
|
32
|
-
|
|
23
|
+
```tsx
|
|
24
|
+
// 1. Define your domain module
|
|
25
|
+
import { DomainModule } from "@sincpro/mobile/framework/domain_module";
|
|
26
|
+
import type { Subscriber } from "@sincpro/mobile/domain/event_sourcing";
|
|
27
|
+
import type { CronWorker } from "@sincpro/mobile/infrastructure/workers";
|
|
33
28
|
|
|
34
|
-
|
|
29
|
+
class SalesModule extends DomainModule {
|
|
30
|
+
readonly key = "SALES";
|
|
31
|
+
readonly name = "Sales";
|
|
35
32
|
|
|
36
|
-
|
|
33
|
+
override subscribers(): Subscriber[] {
|
|
34
|
+
return SalesSubscribers;
|
|
35
|
+
}
|
|
36
|
+
override crons(): CronWorker[] {
|
|
37
|
+
return SalesCronJobs;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
37
40
|
|
|
38
|
-
|
|
41
|
+
const salesModule = new SalesModule();
|
|
42
|
+
|
|
43
|
+
// 2. Wire the app shell — one call does everything
|
|
39
44
|
import { createAppShell, createTheme } from "@sincpro/mobile";
|
|
45
|
+
|
|
46
|
+
export default createAppShell({
|
|
47
|
+
theme: createTheme({ accent: "#0EA5E9" }),
|
|
48
|
+
domains: [salesModule],
|
|
49
|
+
ui: { [salesModule.key]: SalesScreen },
|
|
50
|
+
activeDomain: salesModule.key,
|
|
51
|
+
});
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`createAppShell` runs database migrations, starts the event queue, registers cron workers, applies the theme, loads fonts, and mounts the app — all from a single configuration object.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## 📑 Table of Contents
|
|
59
|
+
|
|
60
|
+
1. [Why This Framework Exists](#why-this-framework-exists)
|
|
61
|
+
2. [Architecture Overview](#architecture-overview)
|
|
62
|
+
3. [Ecosystem](#ecosystem)
|
|
63
|
+
4. [Key Features](#key-features)
|
|
64
|
+
5. [Creating a DomainModule](#creating-a-domainmodule)
|
|
65
|
+
6. [AppShell Configuration](#appshell-configuration)
|
|
66
|
+
7. [Theme Configuration](#theme-configuration)
|
|
67
|
+
8. [Brand Font Configuration](#brand-font-configuration)
|
|
68
|
+
9. [Public API](#public-api)
|
|
69
|
+
10. [Subpaths](#subpaths)
|
|
70
|
+
11. [Development](#development)
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## 🔍 Why This Framework Exists
|
|
75
|
+
|
|
76
|
+
### The Enterprise Mobile Problem
|
|
77
|
+
|
|
78
|
+
Building a business mobile application is not the same as building a consumer app. Business apps need to work in warehouses with no Wi-Fi. They need to print receipts to Bluetooth thermal printers. They need to sync order data when the connection comes back, in the right order, without duplicates. They need to run background jobs that don't drain the battery. They need authentication that safely clears sensitive data on logout. They need dark and light themes that match the brand manual. They need to ship fast and be maintained for years.
|
|
79
|
+
|
|
80
|
+
None of this is hard in isolation. All of it together, across iOS and Android, from scratch, for every project — is expensive, inconsistent, and difficult to maintain.
|
|
81
|
+
|
|
82
|
+
### The AI Era Problem
|
|
83
|
+
|
|
84
|
+
AI tools can generate code faster than ever. That makes the absence of a framework _more_ dangerous, not less. When there is no shared architecture, AI-generated code is consistent only with itself — each prompt produces a different pattern, a different naming convention, a different approach to the same problem. The velocity is real. The maintenance cost is also real.
|
|
85
|
+
|
|
86
|
+
With `@sincpro/mobile`, AI tools have clear patterns to follow: `DomainModule`, `createAppShell`, `Subscriber`, `CronWorker`, `Entity.addDomainEvent`. Code generated within the framework is framework-compliant by default. Human reviewers can audit it quickly. The codebase stays navigable.
|
|
87
|
+
|
|
88
|
+
### The Multi-Platform Bet
|
|
89
|
+
|
|
90
|
+
React Native and Expo are the right foundation for enterprise cross-platform mobile in 2025 and beyond. A single TypeScript codebase, native performance, the full JavaScript ecosystem, and Expo's managed platform for OTA updates and native modules. Sincpro commits to maintaining the enterprise layer on top of this foundation as it evolves — consumers get platform upgrades without rewriting their business logic.
|
|
91
|
+
|
|
92
|
+
### What Sincpro Provides
|
|
93
|
+
|
|
94
|
+
Open-source software is valuable. Maintained open-source software is rare. The difference is people: engineers who triage issues, update dependencies, handle platform breaking changes, document traps they found in production, and preserve behavioral contracts across major versions.
|
|
95
|
+
|
|
96
|
+
Sincpro builds and maintains `@sincpro/mobile` because we build and support the clients that run on it. When a behavior breaks, we fix it. When Expo upgrades their SDK, we absorb the migration. When a new enterprise pattern proves itself in production, we promote it into the framework so every app inherits it. That is the commitment.
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## 🏗️ Architecture Overview
|
|
101
|
+
|
|
102
|
+
The framework is organized in strict layers with enforced one-way dependency flow:
|
|
103
|
+
|
|
104
|
+
```
|
|
105
|
+
┌─────────────────────────────────────────────────────┐
|
|
106
|
+
│ Business Apps │
|
|
107
|
+
│ sincpro-mobile-tickets / distribution / … │
|
|
108
|
+
└────────────────────────┬────────────────────────────┘
|
|
109
|
+
│
|
|
110
|
+
┌────────────────────────▼────────────────────────────┐
|
|
111
|
+
│ @sincpro/mobile-odoo (optional) │
|
|
112
|
+
│ OdooClient · Auth · Partner sync │
|
|
113
|
+
└────────────────────────┬────────────────────────────┘
|
|
114
|
+
│
|
|
115
|
+
┌────────────────────────▼────────────────────────────┐
|
|
116
|
+
│ @sincpro/mobile ◄── YOU ARE HERE │
|
|
117
|
+
│ │
|
|
118
|
+
│ framework/ DomainModule · Kernel · Orchestrator│
|
|
119
|
+
│ domain/ Entity · Event Sourcing · Ports │
|
|
120
|
+
│ infrastructure/ EventBus · CronWorker · DLQ │
|
|
121
|
+
│ adapters/ Network · Bluetooth · Geo · WebView│
|
|
122
|
+
│ services/ Printer · DLQ · Events · Network │
|
|
123
|
+
│ entrypoints/ AppShell · DB · Queue · Cron │
|
|
124
|
+
└────────────────────────┬────────────────────────────┘
|
|
125
|
+
│
|
|
126
|
+
┌────────────────────────▼────────────────────────────┐
|
|
127
|
+
│ @sincpro/mobile-ui │
|
|
128
|
+
│ Design System — no domain knowledge │
|
|
129
|
+
└─────────────────────────────────────────────────────┘
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Key Architectural Principles
|
|
133
|
+
|
|
134
|
+
- **Hexagonal Architecture (Ports and Adapters).** The domain layer defines interfaces (`IPrinterDriver`, `IRepository`). Concrete implementations live outside and are injected. The core never imports hardware SDKs.
|
|
135
|
+
- **Domain-Driven Design.** Each bounded context is a `DomainModule`. The framework orchestrates them; they never know about each other.
|
|
136
|
+
- **Offline-First.** Every domain event is persisted to SQLite before it is processed. Events survive app restarts and process them on recovery.
|
|
137
|
+
- **Strict layer boundaries.** A lower layer cannot import from a higher one. `mobile-ui` has no imports from `@sincpro/mobile`.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## 🗂️ Ecosystem
|
|
142
|
+
|
|
143
|
+
| Package | Role |
|
|
144
|
+
| ---------------------- | -------------------------------------------------------------------- |
|
|
145
|
+
| `@sincpro/mobile` | Core framework: infrastructure, DDD patterns, `AppShell` |
|
|
146
|
+
| `@sincpro/mobile-ui` | Standalone design system (no domain logic) |
|
|
147
|
+
| `@sincpro/mobile-odoo` | **Optional** Odoo 17 integration (OdooClient, auth, partner, server) |
|
|
148
|
+
| `sincpro-mobile-<app>` | Business apps (tickets, distribution) that compose the above |
|
|
149
|
+
|
|
150
|
+
Dependency direction is enforced: apps use everything; `mobile-ui` knows nothing upstream.
|
|
151
|
+
|
|
152
|
+
---
|
|
153
|
+
|
|
154
|
+
## 🔑 Key Features
|
|
155
|
+
|
|
156
|
+
### ✅ Module-Driven Architecture
|
|
157
|
+
|
|
158
|
+
Each bounded context is a class that extends `DomainModule`. It declares its own repositories, migrations, subscribers, and cron workers. The `Kernel` aggregates them all; the `Orchestrator` manages their lifecycle. The core never changes when a domain is added or removed.
|
|
159
|
+
|
|
160
|
+
```tsx
|
|
161
|
+
class InvoiceModule extends DomainModule {
|
|
162
|
+
readonly key = "INVOICE";
|
|
163
|
+
readonly name = "Invoice";
|
|
164
|
+
|
|
165
|
+
override repositories() {
|
|
166
|
+
return {
|
|
167
|
+
invoiceRepo: new InvoiceRepository(),
|
|
168
|
+
invoiceLineRepo: new InvoiceLineRepository(),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
override migrations(): IMigration[] {
|
|
173
|
+
return [CreateInvoicesTable, CreateInvoiceLinesTable];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
override subscribers(): Subscriber[] {
|
|
177
|
+
return [InvoiceCreatedSubscriber, InvoiceVoidedSubscriber, SyncInvoiceSubscriber];
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
override crons(): CronWorker[] {
|
|
181
|
+
return [SyncPendingInvoicesCron];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Tables to preserve when the domain is wiped (e.g. on logout)
|
|
185
|
+
override persistOnReset(): string[] {
|
|
186
|
+
return ["invoice_templates"];
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
### ✅ Offline-First Event Queue
|
|
192
|
+
|
|
193
|
+
Every domain event is persisted to SQLite before any subscriber processes it. If the app is killed mid-processing, the event is replayed on restart. Events requiring network connectivity are held until connectivity returns.
|
|
194
|
+
|
|
195
|
+
**Poll cycle:** EventBus polls every **800ms**, with exponential backoff (×1.5 multiplier, max **30s**) when the queue is empty or the device is offline. The interval resets immediately when a new event is published or connectivity is restored.
|
|
196
|
+
|
|
197
|
+
```tsx
|
|
198
|
+
import { Entity } from "@sincpro/mobile/domain/entity";
|
|
199
|
+
|
|
200
|
+
class Order extends Entity {
|
|
201
|
+
status = "pending";
|
|
202
|
+
|
|
203
|
+
confirm() {
|
|
204
|
+
this.status = "confirmed";
|
|
205
|
+
// Persisted to SQLite before processig — survives app restart
|
|
206
|
+
this.addDomainEvent(OrderConfirmedEvent, { orderId: this.uuid });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Subscriber processes the event asynchronously
|
|
211
|
+
class OrderConfirmedSubscriber extends Subscriber {
|
|
212
|
+
listen = [OrderConfirmedEvent];
|
|
213
|
+
requiresAuth = true;
|
|
214
|
+
|
|
215
|
+
async process(event: OrderConfirmedEvent): Promise<void> {
|
|
216
|
+
await syncOrderToServer(event.orderId);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### ✅ Dead Letter Queue (DLQ)
|
|
222
|
+
|
|
223
|
+
Events that exceed the maximum retry attempts (`MAX_ATTEMPTS = 1` per cycle) are automatically moved to the dead letter queue with the full error message and failure timestamp. Operations teams can inspect, retry in bulk, or permanently delete failed events.
|
|
224
|
+
|
|
225
|
+
```tsx
|
|
226
|
+
import { deadLetterQueueUseCases } from "@sincpro/mobile/services/dlq.service";
|
|
227
|
+
|
|
228
|
+
// Inspect failed events
|
|
229
|
+
const failed = await deadLetterQueueUseCases.getFailedEvents();
|
|
230
|
+
|
|
231
|
+
// Retry a single event (fresh uuid, reset attempts)
|
|
232
|
+
await deadLetterQueueUseCases.retryFailedEvent(failed[0]);
|
|
233
|
+
|
|
234
|
+
// Bulk retry — returns { success: number, failed: number }
|
|
235
|
+
const result = await deadLetterQueueUseCases.retryMultipleFailedEvents(failed);
|
|
236
|
+
|
|
237
|
+
// Remove permanently
|
|
238
|
+
await deadLetterQueueUseCases.deleteFailedEvent(failed[0]);
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### ✅ Domain Entity with Event Sourcing
|
|
242
|
+
|
|
243
|
+
`Entity` is the aggregate root. It collects domain events during a business operation and publishes them atomically. Events carry tracing fields (`correlationId`, `aggregateId`, `sequence`) for transaction support and SAGA patterns.
|
|
244
|
+
|
|
245
|
+
```tsx
|
|
246
|
+
import { Entity } from "@sincpro/mobile/domain/entity";
|
|
247
|
+
|
|
248
|
+
class Invoice extends Entity {
|
|
249
|
+
remoteId?: string;
|
|
250
|
+
status: "draft" | "sent" | "paid" = "draft";
|
|
251
|
+
lines: InvoiceLine[] = [];
|
|
252
|
+
|
|
253
|
+
addLine(line: InvoiceLine) {
|
|
254
|
+
this.lines.push(line);
|
|
255
|
+
// correlationId groups all events from this operation
|
|
256
|
+
this.addDomainEvent(InvoiceLineAddedEvent, { invoiceId: this.uuid, lineId: line.uuid });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
send() {
|
|
260
|
+
this.status = "sent";
|
|
261
|
+
// All events from this call share the same correlationId and sequence
|
|
262
|
+
this.addDomainEvent(InvoiceSentEvent, { invoiceId: this.uuid, sentAt: new Date() });
|
|
263
|
+
await this.publishAllDomainEvents();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
**Entity lifecycle methods:**
|
|
269
|
+
|
|
270
|
+
| Method | Description |
|
|
271
|
+
| --------------------------------- | ----------------------------------------------------------------------------- |
|
|
272
|
+
| `addDomainEvent(Event, payload?)` | Enqueues an event with auto-assigned aggregateId, correlationId, and sequence |
|
|
273
|
+
| `addDomainEventWithEntity(Event)` | Enqueues event with the entire entity as the `record` field |
|
|
274
|
+
| `publishAllDomainEvents()` | Async: persists all pending events, clears the internal queue |
|
|
275
|
+
| `publishAllDomainEventsSync()` | Sync: publishes immediately, throws on any failure |
|
|
276
|
+
| `copyDomainEventsFrom(source)` | Transfers events from another entity (re-aggregating) |
|
|
277
|
+
|
|
278
|
+
### ✅ Remote Entity Sync
|
|
279
|
+
|
|
280
|
+
`RemoteEntity` extends `Entity` with sync state tracking for entities that mirror data from a remote server. It manages the full lifecycle from pending local creation to confirmed remote sync.
|
|
281
|
+
|
|
282
|
+
```tsx
|
|
283
|
+
import { RemoteEntity } from "@sincpro/mobile/domain/entity";
|
|
284
|
+
|
|
285
|
+
class Product extends RemoteEntity {
|
|
286
|
+
name: string = "";
|
|
287
|
+
price: number = 0;
|
|
288
|
+
// remoteState: "SYNCED" | "PENDING" | "FAILED"
|
|
289
|
+
// remoteId?: string — the server's ID once synced
|
|
290
|
+
|
|
291
|
+
static fromApiResponse(data: ApiProduct): Product {
|
|
292
|
+
return Product.fromRemoteDTO(data);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
syncWithServer(updated: ApiProduct) {
|
|
296
|
+
this.mergeWithRemote(updated); // preserves uuid + eventIds
|
|
297
|
+
this.markAsSynced();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
toApiPayload() {
|
|
301
|
+
return this.remotePayload(); // serializes to API format
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### ✅ Background Cron Workers
|
|
307
|
+
|
|
308
|
+
`CronWorker` schedules recurring background tasks. Workers under 15 minutes use `setInterval` (foreground); workers at or above 15 minutes register with `expo-background-task` for true background execution.
|
|
309
|
+
|
|
310
|
+
```tsx
|
|
311
|
+
import { CronWorker } from "@sincpro/mobile/infrastructure/workers";
|
|
312
|
+
|
|
313
|
+
const SyncInvoicesCron = new CronWorker(
|
|
314
|
+
"sync-invoices", // task name (must be globally unique)
|
|
315
|
+
async () => {
|
|
316
|
+
const pending = await invoiceRepo.findPendingSync();
|
|
317
|
+
for (const invoice of pending) {
|
|
318
|
+
await apiClient.syncInvoice(invoice);
|
|
319
|
+
}
|
|
320
|
+
},
|
|
321
|
+
15, // interval in minutes (>= 15 → background capable)
|
|
322
|
+
true, // requiresAuth — won't run if the session is unauthenticated
|
|
323
|
+
);
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
The `Orchestrator` starts, stops, and re-syncs workers automatically when domains are activated/deactivated or when the authentication state changes.
|
|
327
|
+
|
|
328
|
+
### ✅ Authentication Lifecycle
|
|
329
|
+
|
|
330
|
+
The `Orchestrator` manages a global authentication state that gates subscribers and cron workers. Marking the session unauthenticated can optionally wipe the event queue and reset the database — leaving only the tables declared in `persistOnReset()`.
|
|
331
|
+
|
|
332
|
+
```tsx
|
|
333
|
+
import { orchestrator } from "@sincpro/mobile";
|
|
334
|
+
|
|
335
|
+
// User signs in
|
|
336
|
+
await orchestrator.authenticateSession();
|
|
337
|
+
// → Re-syncs queue subscribers and cron workers for active domains
|
|
338
|
+
|
|
339
|
+
// User signs out — clears queue + resets DB (except persisted tables)
|
|
340
|
+
await orchestrator.unauthenticateSession(true);
|
|
341
|
+
|
|
342
|
+
// Resume after app goes to background and returns
|
|
343
|
+
await orchestrator.restoreSession();
|
|
344
|
+
// → Drains the event queue and re-syncs crons
|
|
345
|
+
|
|
346
|
+
// Domain enable / disable at runtime
|
|
347
|
+
await orchestrator.enableDomain("ODOO");
|
|
348
|
+
await orchestrator.disableDomain("ODOO");
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
All state transitions are protected by a mutex — concurrent calls are serialized.
|
|
352
|
+
|
|
353
|
+
### ✅ Connectivity Awareness
|
|
354
|
+
|
|
355
|
+
The framework knows whether the device has internet access and routes work accordingly. Events marked `requiresNetwork = true` are held in the queue until connectivity is confirmed. Subscribers can respond directly to connectivity changes via domain events.
|
|
356
|
+
|
|
357
|
+
```tsx
|
|
358
|
+
class SyncOrderEvent extends DomainEvent {
|
|
359
|
+
static requiresNetwork = true; // Won't process while offline
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Check status at any point
|
|
363
|
+
import { networkUseCases } from "@sincpro/mobile/services/network.service";
|
|
364
|
+
|
|
365
|
+
const status = await networkUseCases.getNetworkStatus();
|
|
366
|
+
// { isConnected: boolean, isInternetReachable: boolean | null, type: "wifi"|"cellular"|…, ipAddress }
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
The EventBus subscribes to `InternetIsDownEvent` / `InternetIsUpEvent` internally — when connectivity is lost it increases the poll interval, and when it is restored it resets to 800ms and drains any held events immediately.
|
|
370
|
+
|
|
371
|
+
### ✅ Bluetooth Printer Integration
|
|
372
|
+
|
|
373
|
+
The printer port (`IPrinterDriver`) is defined by the framework; the concrete driver is registered by the app. The `PrinterService` wraps it with connection management, permission handling, and a grace-reconnect strategy.
|
|
374
|
+
|
|
375
|
+
```tsx
|
|
376
|
+
// Register the adapter before AppShell boots
|
|
377
|
+
import { printerService } from "@sincpro/mobile/services/printer.service";
|
|
378
|
+
import { PrinterAdapter } from "./adapters/Printer.adapter"; // your Bluetooth impl
|
|
379
|
+
|
|
380
|
+
printerService.setDriver(PrinterAdapter);
|
|
381
|
+
|
|
382
|
+
// Then use the service anywhere
|
|
383
|
+
await printerService.connect(deviceAddress, "Printer Name");
|
|
384
|
+
await printerService.printText("=== RECEIPT ===\nItem: Widget\nTotal: $12.00");
|
|
385
|
+
await printerService.printImageBase64(base64Png);
|
|
386
|
+
await printerService.printHtml(htmlString); // converts to PDF (576px thermal width)
|
|
387
|
+
|
|
388
|
+
// Capture a React Native view and print it
|
|
389
|
+
await printerService.printFromView(receiptViewRef);
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
**Grace reconnect:** If printing fails, the service automatically disconnects, reconnects to the last saved printer, and retries once — without surfacing the error to the user.
|
|
393
|
+
|
|
394
|
+
### ✅ WebView Print Interception
|
|
395
|
+
|
|
396
|
+
The framework ships injectable scripts that intercept `window.print()` calls inside a WebView, capture the document as a PNG via `html2canvas`, and forward it to React Native for Bluetooth printing.
|
|
397
|
+
|
|
398
|
+
```tsx
|
|
399
|
+
import { WebViewAdapter } from "@sincpro/mobile/adapters/webview";
|
|
400
|
+
|
|
401
|
+
<WebView
|
|
402
|
+
injectedJavaScriptBeforeContentLoaded={WebViewAdapter.getDefaultInterceptors()
|
|
403
|
+
.map((s) => s.script)
|
|
404
|
+
.join("\n")}
|
|
405
|
+
onMessage={({ nativeEvent }) => {
|
|
406
|
+
// Framework base_module handles PRINT_IMAGE automatically
|
|
407
|
+
// via ProcessWebViewMessageSubscriber → PrintImageSubscriber
|
|
408
|
+
EventBus.publishSync(new WebViewMessageReceivedEvent({ raw: nativeEvent.data }));
|
|
409
|
+
}}
|
|
410
|
+
/>;
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
Available interceptors: `printInterceptor` (default), `ajaxInterceptor` (debug), `contentHeightReporter`.
|
|
414
|
+
|
|
415
|
+
### ✅ Activity Monitoring
|
|
416
|
+
|
|
417
|
+
`useCommon()` exposes real-time visibility into queue and cron activity — what is running, what failed, and how long it has been running. Use this to build status indicators, loading states, and error recovery UI.
|
|
418
|
+
|
|
419
|
+
```tsx
|
|
420
|
+
import { useCommon } from "@sincpro/mobile";
|
|
421
|
+
|
|
422
|
+
function StatusBar() {
|
|
423
|
+
const { currentActivity, lastError, isProcessing, dismiss } = useCommon();
|
|
424
|
+
|
|
425
|
+
if (lastError)
|
|
426
|
+
return (
|
|
427
|
+
<Banner variant="error" onDismiss={dismiss}>
|
|
428
|
+
{lastError.label} failed: {lastError.error}
|
|
429
|
+
</Banner>
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
if (isProcessing) return <ActivityIndicator label={currentActivity?.label ?? "Working…"} />;
|
|
433
|
+
|
|
434
|
+
return null;
|
|
435
|
+
}
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
**`ICommonContext` full shape:**
|
|
439
|
+
|
|
440
|
+
| Field | Type | Description |
|
|
441
|
+
| ------------------------ | ------------------------------- | ---------------------------------------- |
|
|
442
|
+
| `currentActivity` | `Activity \| undefined` | Currently running queue or cron task |
|
|
443
|
+
| `lastError` | `Activity \| undefined` | Last failed task (auto-clears after 10s) |
|
|
444
|
+
| `isProcessing` | `boolean` | True while any task is active or errored |
|
|
445
|
+
| `dismiss()` | `() => void` | Clears `lastError` |
|
|
446
|
+
| `debugMode` | `boolean` | Developer debug toggle (persisted) |
|
|
447
|
+
| `colorScheme` | `"light" \| "dark" \| "system"` | Current color scheme (persisted) |
|
|
448
|
+
| `setColorScheme(scheme)` | function | Switches theme + persists preference |
|
|
449
|
+
| `timezone` | `string \| null` | Persisted timezone setting |
|
|
450
|
+
| `updateTimezone(tz)` | function | Updates and persists |
|
|
451
|
+
| `hasGeoPermission` | `boolean` | Current geo permission state |
|
|
452
|
+
| `requestGeoPermission()` | `async () => boolean` | Requests location permission |
|
|
453
|
+
| `checkGeoPermission()` | `async () => void` | Rechecks without prompting |
|
|
454
|
+
|
|
455
|
+
### ✅ Domain Switcher
|
|
456
|
+
|
|
457
|
+
Apps that compose multiple domains can switch the active UI at runtime without any navigation library setup. The domain that is not displayed is still running — its queue subscribers and cron workers remain active.
|
|
458
|
+
|
|
459
|
+
```tsx
|
|
460
|
+
import { useDomainSwitcher } from "@sincpro/mobile";
|
|
461
|
+
|
|
462
|
+
function AppSwitcher() {
|
|
463
|
+
const { activeDomain, switchTo } = useDomainSwitcher();
|
|
464
|
+
|
|
465
|
+
return (
|
|
466
|
+
<Navigation.SegmentedControl
|
|
467
|
+
value={activeDomain}
|
|
468
|
+
options={[
|
|
469
|
+
{ value: "TICKETS", label: "Tickets" },
|
|
470
|
+
{ value: "ODOO", label: "Odoo" },
|
|
471
|
+
]}
|
|
472
|
+
onChange={switchTo}
|
|
473
|
+
/>
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
### ✅ Global Error Handling
|
|
479
|
+
|
|
480
|
+
`DomainException` instances that escape to the React root are bridged to native `Alert` dialogs automatically:
|
|
481
|
+
|
|
482
|
+
```tsx
|
|
483
|
+
import { installGlobalErrorHandler } from "@sincpro/mobile";
|
|
484
|
+
|
|
485
|
+
// Call once before createAppShell
|
|
486
|
+
installGlobalErrorHandler();
|
|
487
|
+
```
|
|
488
|
+
|
|
489
|
+
---
|
|
490
|
+
|
|
491
|
+
## 🛠️ Creating a DomainModule
|
|
492
|
+
|
|
493
|
+
A `DomainModule` is the primary building block. Here is how to create one from scratch:
|
|
494
|
+
|
|
495
|
+
### Step 1 — Define the module class
|
|
496
|
+
|
|
497
|
+
```tsx
|
|
40
498
|
import { DomainModule } from "@sincpro/mobile/framework/domain_module";
|
|
41
499
|
import type { Subscriber } from "@sincpro/mobile/domain/event_sourcing";
|
|
500
|
+
import type { CronWorker } from "@sincpro/mobile/infrastructure/workers";
|
|
501
|
+
import type { IMigration } from "@sincpro/mobile/domain/database";
|
|
42
502
|
|
|
43
|
-
class
|
|
44
|
-
readonly key = "
|
|
45
|
-
readonly name = "
|
|
503
|
+
export class TicketsModule extends DomainModule {
|
|
504
|
+
readonly key = "TICKETS"; // unique — used as the ui map key
|
|
505
|
+
readonly name = "Tickets"; // human-readable
|
|
506
|
+
|
|
507
|
+
override repositories() {
|
|
508
|
+
return {
|
|
509
|
+
ticketRepo: new TicketRepository(),
|
|
510
|
+
ticketLineRepo: new TicketLineRepository(),
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
override migrations(): IMigration[] {
|
|
515
|
+
return [CreateTicketsTable, CreateTicketLinesTable];
|
|
516
|
+
}
|
|
46
517
|
|
|
47
518
|
override subscribers(): Subscriber[] {
|
|
48
|
-
return [];
|
|
519
|
+
return [TicketCreatedSubscriber, TicketPrintSubscriber, SyncTicketSubscriber];
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
override crons(): CronWorker[] {
|
|
523
|
+
return [SyncPendingTicketsCron];
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Preserved during logout wipe — ticket templates survive session reset
|
|
527
|
+
override persistOnReset(): string[] {
|
|
528
|
+
return ["ticket_templates", "ticket_categories"];
|
|
49
529
|
}
|
|
50
530
|
}
|
|
51
531
|
|
|
52
|
-
const
|
|
532
|
+
export const ticketsModule = new TicketsModule();
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
### Step 2 — Module hook reference
|
|
536
|
+
|
|
537
|
+
| Hook | Returns | When it runs | Can assume |
|
|
538
|
+
| ------------------ | ----------------------------- | ------------------------- | ------------------ |
|
|
539
|
+
| `repositories()` | `Record<string, IRepository>` | Kernel bootstrap | Nothing yet |
|
|
540
|
+
| `migrations()` | `IMigration[]` | After repos registered | Repos registered |
|
|
541
|
+
| `subscribers()` | `Subscriber[]` | After migrations | DB schema ready |
|
|
542
|
+
| `crons()` | `CronWorker[]` | After subscribers | Queue running |
|
|
543
|
+
| `persistOnReset()` | `string[]` table names | On `unauthenticate(true)` | Called during wipe |
|
|
544
|
+
|
|
545
|
+
All hooks return safe empty defaults. Override only what your domain needs.
|
|
546
|
+
|
|
547
|
+
### Step 3 — Wire the module in the app shell
|
|
548
|
+
|
|
549
|
+
```tsx
|
|
550
|
+
export function createTicketsApp(): ComponentType {
|
|
551
|
+
// Register adapters before the shell boots
|
|
552
|
+
printerService.setDriver(PrinterAdapter);
|
|
553
|
+
|
|
554
|
+
return createAppShell({
|
|
555
|
+
theme: createTheme(TICKETS_THEME),
|
|
556
|
+
darkTheme: createTheme(TICKETS_DARK_THEME),
|
|
557
|
+
brandFont: { files: { … }, families: { … } },
|
|
558
|
+
domains: [odooModule, ticketsModule],
|
|
559
|
+
ui: { [ticketsModule.key]: TicketsApp },
|
|
560
|
+
activeDomain: ticketsModule.key,
|
|
561
|
+
providers: [ConfirmationProvider, ProcessToastProvider],
|
|
562
|
+
splashComponent: TicketsSplash,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
### Recommended project layout
|
|
568
|
+
|
|
569
|
+
```
|
|
570
|
+
sincpro_mobile_myapp/
|
|
571
|
+
├── assets/
|
|
572
|
+
│ └── fonts/ ← brand typeface files (.otf / .ttf)
|
|
573
|
+
├── sincpro_mobile_myapp/
|
|
574
|
+
│ ├── entrypoints/
|
|
575
|
+
│ │ ├── main.tsx ← DomainModule class + createMyApp()
|
|
576
|
+
│ │ ├── cron/ ← CronWorker definitions
|
|
577
|
+
│ │ ├── queue/ ← Subscriber definitions
|
|
578
|
+
│ │ └── ui/
|
|
579
|
+
│ │ ├── App.tsx ← root screen component
|
|
580
|
+
│ │ └── theme/
|
|
581
|
+
│ │ └── tokens.ts ← ThemeTokens (light + dark)
|
|
582
|
+
│ ├── adapters/ ← concrete port implementations (Printer, API, …)
|
|
583
|
+
│ ├── domain/ ← entities, value objects, domain events, port interfaces
|
|
584
|
+
│ ├── application/ ← use cases
|
|
585
|
+
│ └── infrastructure/ ← repositories, remote clients
|
|
586
|
+
└── app/
|
|
587
|
+
└── index.tsx ← calls createMyApp(), exports default
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
---
|
|
591
|
+
|
|
592
|
+
## ⚙️ AppShell Configuration
|
|
593
|
+
|
|
594
|
+
`createAppShell(config)` is the single composition root. All infrastructure wires up from this one call.
|
|
595
|
+
|
|
596
|
+
```tsx
|
|
597
|
+
import { createAppShell, createTheme } from "@sincpro/mobile";
|
|
53
598
|
|
|
54
599
|
export default createAppShell({
|
|
55
|
-
|
|
56
|
-
domains:
|
|
57
|
-
ui:
|
|
58
|
-
activeDomain:
|
|
600
|
+
// ── Required ─────────────────────────────────────────────────────────────────
|
|
601
|
+
domains: [odooModule, ticketsModule], // modules to bootstrap (order matters for shared deps)
|
|
602
|
+
ui: { [ticketsModule.key]: TicketsApp }, // domain key → screen component
|
|
603
|
+
activeDomain: ticketsModule.key, // which UI to show on launch
|
|
604
|
+
|
|
605
|
+
// ── Theme ────────────────────────────────────────────────────────────────────
|
|
606
|
+
theme: createTheme(TICKETS_THEME),
|
|
607
|
+
darkTheme: createTheme(TICKETS_DARK_THEME),
|
|
608
|
+
|
|
609
|
+
// ── Brand fonts ──────────────────────────────────────────────────────────────
|
|
610
|
+
brandFont: { files: { … }, families: { … } },
|
|
611
|
+
|
|
612
|
+
// ── Additional React context providers ───────────────────────────────────────
|
|
613
|
+
providers: [ConfirmationProvider, ProcessToastProvider],
|
|
614
|
+
|
|
615
|
+
// ── Splash screen ────────────────────────────────────────────────────────────
|
|
616
|
+
splashComponent: TicketsSplash,
|
|
617
|
+
splashDuration: 2000, // ms minimum (default 2000)
|
|
618
|
+
});
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
**Splash gate.** The splash stays visible until **all three** conditions are true simultaneously: domain bootstrap complete, brand fonts loaded by the native font registry, and minimum duration elapsed. This prevents flash-of-unstyled-content and loading flicker on any device speed.
|
|
622
|
+
|
|
623
|
+
**Background domains.** Domains in `domains` without a matching key in `ui` are still fully bootstrapped — their queue, cron, repos, and migrations run normally. They just render no UI. This is how `@sincpro/mobile-odoo` works when included.
|
|
624
|
+
|
|
625
|
+
**Provider wrap order.** `[A, B, C]` renders as `<A><B><C>{app}</C></B></A>`. The last item is the outermost wrapper.
|
|
626
|
+
|
|
627
|
+
---
|
|
628
|
+
|
|
629
|
+
## 🎨 Theme Configuration
|
|
630
|
+
|
|
631
|
+
```tsx
|
|
632
|
+
// entrypoints/ui/theme/tokens.ts
|
|
633
|
+
import type { ThemeTokens } from "@sincpro/mobile-ui/theme/types";
|
|
634
|
+
|
|
635
|
+
export const MY_THEME: ThemeTokens = {
|
|
636
|
+
name: "my-app",
|
|
637
|
+
primary: "#0B0B0B", // buttons, active text
|
|
638
|
+
secondary: "#6B6963", // muted neutral
|
|
639
|
+
accent: "#00C357", // CTA, focus rings, active states
|
|
640
|
+
|
|
641
|
+
bg: {
|
|
642
|
+
page: "#FAFAFA",
|
|
643
|
+
card: "#FFFFFF",
|
|
644
|
+
popover: "#FFFFFF",
|
|
645
|
+
muted: "#EDEBE6",
|
|
646
|
+
accent: "#E6FFF2", // selected surface (tinted)
|
|
647
|
+
hover: "#F0F1EE",
|
|
648
|
+
disabled: "#F1F1EF",
|
|
649
|
+
},
|
|
650
|
+
|
|
651
|
+
text: {
|
|
652
|
+
primary: "#0B0B0B",
|
|
653
|
+
secondary: "#46443F",
|
|
654
|
+
accent: "#006E2E", // text on accent-tinted bg
|
|
655
|
+
inverse: "#FFFFFF",
|
|
656
|
+
onPrimary: "#FFFFFF",
|
|
657
|
+
onAccent: "#FFFFFF",
|
|
658
|
+
// …full shape in @sincpro/mobile-ui README
|
|
659
|
+
},
|
|
660
|
+
|
|
661
|
+
border: { default: "#E4E2DD", light: "#EFEDE8", strong: "#D1D5DB", focus: "#00C357" },
|
|
662
|
+
success: "#00C357", warning: "#FFB020", danger: "#FF4D1F", info: "#201DC4",
|
|
663
|
+
ring: "#00C357",
|
|
664
|
+
input: "#E4E2DD",
|
|
665
|
+
gradient: { primary: ["#1A1A1A", "#0B0B0B"], accent: ["#00A34A", "#00C357"] },
|
|
666
|
+
shadow: { sm: { … }, md: { … }, lg: { … } },
|
|
667
|
+
};
|
|
668
|
+
```
|
|
669
|
+
|
|
670
|
+
Pass both `theme` and `darkTheme` to `createAppShell`. The framework switches automatically with the system color scheme. `useCommon().colorScheme` exposes the current value and `setColorScheme()` lets the user override it manually (persisted to the settings table).
|
|
671
|
+
|
|
672
|
+
---
|
|
673
|
+
|
|
674
|
+
## 🔤 Brand Font Configuration
|
|
675
|
+
|
|
676
|
+
The framework loads **only** the fonts you declare — nothing is assumed or loaded silently. Font files are owned by the app; the framework wires them into the DS typography roles.
|
|
677
|
+
|
|
678
|
+
### The four semantic slots (aligned with the brand manual)
|
|
679
|
+
|
|
680
|
+
| Slot | Brand label | Purpose | DS default |
|
|
681
|
+
| --------- | ----------- | -------------------------------- | ---------- |
|
|
682
|
+
| `display` | `df` | Titles, headings, hero text | Inter |
|
|
683
|
+
| `body` | `bf` | Body, labels, buttons (13–15px) | Inter |
|
|
684
|
+
| `caption` | `cf` | Captions, overlines, metadata | Fira Code |
|
|
685
|
+
| `mono` | `mono` | Amounts, SKU codes, numeric data | Fira Code |
|
|
686
|
+
|
|
687
|
+
Most apps only need to declare `display`. Omitting a slot keeps the DS default.
|
|
688
|
+
|
|
689
|
+
```tsx
|
|
690
|
+
import { FiraCode_400Regular, FiraCode_500Medium } from "@expo-google-fonts/fira-code";
|
|
691
|
+
import {
|
|
692
|
+
Inter_300Light,
|
|
693
|
+
Inter_400Regular,
|
|
694
|
+
Inter_500Medium,
|
|
695
|
+
Inter_600SemiBold,
|
|
696
|
+
Inter_800ExtraBold,
|
|
697
|
+
} from "@expo-google-fonts/inter";
|
|
698
|
+
|
|
699
|
+
createAppShell({
|
|
700
|
+
brandFont: {
|
|
701
|
+
files: {
|
|
702
|
+
// df — Display (brand typeface, local .otf files)
|
|
703
|
+
"Satoshi-Regular": require("../../assets/fonts/Satoshi-Regular.otf"),
|
|
704
|
+
"Satoshi-Medium": require("../../assets/fonts/Satoshi-Medium.otf"),
|
|
705
|
+
"Satoshi-Bold": require("../../assets/fonts/Satoshi-Bold.otf"),
|
|
706
|
+
"Satoshi-Black": require("../../assets/fonts/Satoshi-Black.otf"),
|
|
707
|
+
// bf — Body (Inter via @expo-google-fonts/inter)
|
|
708
|
+
Inter_300Light,
|
|
709
|
+
Inter_400Regular,
|
|
710
|
+
Inter_500Medium,
|
|
711
|
+
Inter_600SemiBold,
|
|
712
|
+
Inter_800ExtraBold,
|
|
713
|
+
// cf + mono (Fira Code via @expo-google-fonts/fira-code)
|
|
714
|
+
FiraCode_400Regular,
|
|
715
|
+
FiraCode_500Medium,
|
|
716
|
+
},
|
|
717
|
+
families: {
|
|
718
|
+
display: {
|
|
719
|
+
regular: "Satoshi-Regular",
|
|
720
|
+
medium: "Satoshi-Medium",
|
|
721
|
+
bold: "Satoshi-Bold",
|
|
722
|
+
black: "Satoshi-Black",
|
|
723
|
+
},
|
|
724
|
+
body: {
|
|
725
|
+
light: "Inter_300Light",
|
|
726
|
+
regular: "Inter_400Regular",
|
|
727
|
+
medium: "Inter_500Medium",
|
|
728
|
+
semiBold: "Inter_600SemiBold",
|
|
729
|
+
extraBold: "Inter_800ExtraBold",
|
|
730
|
+
},
|
|
731
|
+
caption: { regular: "FiraCode_400Regular", medium: "FiraCode_500Medium" },
|
|
732
|
+
mono: { regular: "FiraCode_400Regular", medium: "FiraCode_500Medium" },
|
|
733
|
+
},
|
|
734
|
+
},
|
|
59
735
|
});
|
|
60
736
|
```
|
|
61
737
|
|
|
62
|
-
|
|
738
|
+
---
|
|
739
|
+
|
|
740
|
+
## 📖 Public API
|
|
63
741
|
|
|
64
|
-
|
|
742
|
+
### `createAppShell(config)` → `ComponentType`
|
|
65
743
|
|
|
66
|
-
|
|
67
|
-
- `DomainModule` — clase base abstracta de un dominio (`key`, `name`, `shared?`, `repositories()`, `migrations()`, `subscribers()`, `crons()`, `persistOnReset()`).
|
|
68
|
-
- `createTheme(overrides?)` — theme tipado (defaults del framework + overrides parciales).
|
|
69
|
-
- `createApp(config)` / `Kernel` / `orchestrator` — capa baja de composición (bootstrap sin UI).
|
|
70
|
-
- `BaseModule` / `baseModule` — módulo común (COMMON) que el framework registra por defecto.
|
|
71
|
-
- `CommonProvider` / `useCommon` — estado común (debug, geo, timezone, actividad de cola/cron).
|
|
72
|
-
- `PlainLayout` / `TabNavigatorLayout` — layouts conectados a react-router.
|
|
73
|
-
- `installGlobalErrorHandler()` — handler global (errores de dominio → Alert).
|
|
744
|
+
Composition root. Returns a React component ready to export as the Expo entry point.
|
|
74
745
|
|
|
75
|
-
|
|
746
|
+
### `DomainModule` (abstract class)
|
|
76
747
|
|
|
77
|
-
|
|
748
|
+
```tsx
|
|
749
|
+
abstract class DomainModule {
|
|
750
|
+
abstract readonly key: string; // unique, used as ui map key
|
|
751
|
+
abstract readonly name: string; // human-readable label
|
|
752
|
+
readonly shared = false; // shared modules bootstrap first, cannot be disabled
|
|
753
|
+
|
|
754
|
+
repositories(): Record<string, object> {
|
|
755
|
+
return {};
|
|
756
|
+
}
|
|
757
|
+
migrations(): IMigration[] {
|
|
758
|
+
return [];
|
|
759
|
+
}
|
|
760
|
+
subscribers(): Subscriber[] {
|
|
761
|
+
return [];
|
|
762
|
+
}
|
|
763
|
+
crons(): CronWorker[] {
|
|
764
|
+
return [];
|
|
765
|
+
}
|
|
766
|
+
persistOnReset(): string[] {
|
|
767
|
+
return [];
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
```
|
|
771
|
+
|
|
772
|
+
### `createTheme(overrides?)` → `ThemeTokens`
|
|
773
|
+
|
|
774
|
+
Deep-merges partial overrides on top of the DS base theme.
|
|
775
|
+
|
|
776
|
+
### `createApp(config?)` → `Promise<Kernel>`
|
|
777
|
+
|
|
778
|
+
Low-level bootstrap without UI. Use for headless contexts (background workers, test harnesses).
|
|
779
|
+
|
|
780
|
+
### `orchestrator`
|
|
781
|
+
|
|
782
|
+
Singleton managing authentication state, domain activation, and queue/cron sync. Methods: `authenticateSession()`, `unauthenticateSession(cleanData?)`, `restoreSession()`, `enableDomain(key)`, `disableDomain(key)`, `isSessionAuthenticated()`.
|
|
783
|
+
|
|
784
|
+
### `useCommon()` → `ICommonContext`
|
|
785
|
+
|
|
786
|
+
```tsx
|
|
787
|
+
const {
|
|
788
|
+
currentActivity,
|
|
789
|
+
lastError,
|
|
790
|
+
isProcessing,
|
|
791
|
+
dismiss,
|
|
792
|
+
colorScheme,
|
|
793
|
+
setColorScheme,
|
|
794
|
+
timezone,
|
|
795
|
+
updateTimezone,
|
|
796
|
+
hasGeoPermission,
|
|
797
|
+
requestGeoPermission,
|
|
798
|
+
debugMode,
|
|
799
|
+
} = useCommon();
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
### `useDomainSwitcher()` → `{ activeDomain, switchTo }`
|
|
803
|
+
|
|
804
|
+
```tsx
|
|
805
|
+
const { activeDomain, switchTo } = useDomainSwitcher();
|
|
806
|
+
switchTo("ODOO");
|
|
807
|
+
```
|
|
808
|
+
|
|
809
|
+
### `installGlobalErrorHandler()`
|
|
810
|
+
|
|
811
|
+
Bridges `DomainException` to native `Alert`. Call once before `createAppShell`.
|
|
812
|
+
|
|
813
|
+
### `baseModule` / `BaseModule`
|
|
814
|
+
|
|
815
|
+
Built-in COMMON module. Always registered automatically — do not pass it to `domains`.
|
|
816
|
+
|
|
817
|
+
---
|
|
818
|
+
|
|
819
|
+
## 📦 Subpaths
|
|
78
820
|
|
|
79
821
|
```ts
|
|
822
|
+
// Framework primitives
|
|
80
823
|
import { DomainModule } from "@sincpro/mobile/framework/domain_module";
|
|
824
|
+
import { orchestrator } from "@sincpro/mobile/framework/orchestrator";
|
|
825
|
+
import { Kernel } from "@sincpro/mobile/framework/kernel";
|
|
826
|
+
|
|
827
|
+
// Domain layer
|
|
81
828
|
import type { Subscriber } from "@sincpro/mobile/domain/event_sourcing";
|
|
82
|
-
import {
|
|
829
|
+
import { Entity } from "@sincpro/mobile/domain/entity";
|
|
830
|
+
import { RemoteEntity } from "@sincpro/mobile/domain/entity";
|
|
831
|
+
import type { IMigration } from "@sincpro/mobile/domain/database";
|
|
832
|
+
|
|
833
|
+
// Infrastructure
|
|
834
|
+
import type { CronWorker } from "@sincpro/mobile/infrastructure/workers";
|
|
835
|
+
import { EventBus } from "@sincpro/mobile/infrastructure/workers";
|
|
836
|
+
|
|
837
|
+
// Services
|
|
838
|
+
import { printerService } from "@sincpro/mobile/services/printer.service";
|
|
839
|
+
import { deadLetterQueueUseCases } from "@sincpro/mobile/services/dlq.service";
|
|
840
|
+
import { networkUseCases } from "@sincpro/mobile/services/network.service";
|
|
841
|
+
|
|
842
|
+
// Adapters
|
|
843
|
+
import { WebViewAdapter } from "@sincpro/mobile/adapters/webview";
|
|
844
|
+
import { NetworkAdapter } from "@sincpro/mobile/adapters/network";
|
|
845
|
+
import { GeoAdapter } from "@sincpro/mobile/adapters/geo";
|
|
846
|
+
import { ReceiptExporterAdapter } from "@sincpro/mobile/adapters/receipt_exporter";
|
|
847
|
+
|
|
848
|
+
// UI components (screens, events)
|
|
849
|
+
import { PlainLayout } from "@sincpro/mobile/ui/screens/events";
|
|
83
850
|
```
|
|
84
851
|
|
|
85
|
-
|
|
852
|
+
Full map in `package.json` under `exports`.
|
|
853
|
+
|
|
854
|
+
---
|
|
855
|
+
|
|
856
|
+
## 🔧 Development
|
|
86
857
|
|
|
87
|
-
|
|
858
|
+
All tasks run through `make`:
|
|
88
859
|
|
|
89
|
-
|
|
860
|
+
| Command | What it does |
|
|
861
|
+
| -------------------- | ------------------------------------------------------ |
|
|
862
|
+
| `make build` | `tsc` + `tsc-alias` → `dist/` (JS + type declarations) |
|
|
863
|
+
| `make format` | Auto-fix: ESLint + Prettier + type-check |
|
|
864
|
+
| `make verify-format` | CI gate — runs `format`, fails if any file changed |
|
|
865
|
+
| `make publish` | Bump version + `npm publish` |
|
|
866
|
+
|
|
867
|
+
**Local monorepo propagation** (no symlinks setup):
|
|
868
|
+
|
|
869
|
+
```bash
|
|
870
|
+
# After make build — copy dist to each consumer
|
|
871
|
+
cp -r dist/* ../sincpro_mobile_tickets/node_modules/@sincpro/mobile/dist/
|
|
872
|
+
cp -r dist/* ../sincpro_mobile_distribution/node_modules/@sincpro/mobile/dist/
|
|
873
|
+
```
|
|
90
874
|
|
|
91
|
-
|
|
875
|
+
Detailed workflow, naming conventions, architectural decisions, and AI guardrails: [`AGENTS.md`](AGENTS.md).
|
|
@@ -5,15 +5,69 @@ import { type ComponentType, type ReactNode } from "react";
|
|
|
5
5
|
type ProviderComponent = ComponentType<{
|
|
6
6
|
children: ReactNode;
|
|
7
7
|
}>;
|
|
8
|
+
export interface BrandFontWeights {
|
|
9
|
+
light?: string;
|
|
10
|
+
regular?: string;
|
|
11
|
+
medium?: string;
|
|
12
|
+
semiBold?: string;
|
|
13
|
+
bold?: string;
|
|
14
|
+
extraBold?: string;
|
|
15
|
+
black?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface BrandFontFamilies {
|
|
18
|
+
/** df — Display & headings. Brand character font. (Default: Inter) */
|
|
19
|
+
display?: BrandFontWeights;
|
|
20
|
+
/** bf — Body, labels, buttons. Interface text 13–15px. (Default: Inter) */
|
|
21
|
+
body?: BrandFontWeights;
|
|
22
|
+
/** cf — Captions, overlines, metadata. Uppercase + tracking. (Default: Fira Code) */
|
|
23
|
+
caption?: Pick<BrandFontWeights, "regular" | "medium">;
|
|
24
|
+
/** mono — Numeric data, SKU, amounts. Fixed-width. (Default: Fira Code) */
|
|
25
|
+
mono?: Pick<BrandFontWeights, "regular" | "medium">;
|
|
26
|
+
}
|
|
27
|
+
export interface BrandFontConfig {
|
|
28
|
+
/** Metro asset map: `{ "Satoshi-Bold": require("./assets/fonts/Satoshi-Bold.otf") }` */
|
|
29
|
+
files: Record<string, string | number>;
|
|
30
|
+
/** Semantic font family slots aligned with the brand manual (df / bf / cf / mono). */
|
|
31
|
+
families: BrandFontFamilies;
|
|
32
|
+
}
|
|
8
33
|
export interface AppShellConfig {
|
|
34
|
+
/**
|
|
35
|
+
* Domain modules to bootstrap. Each module registers its own repositories,
|
|
36
|
+
* migrations, subscribers, and cron workers. The built-in `baseModule` is
|
|
37
|
+
* always included automatically — do not pass it here.
|
|
38
|
+
*/
|
|
9
39
|
domains: DomainModule[];
|
|
40
|
+
/**
|
|
41
|
+
* Map of domain key → screen component. Domains without a matching entry
|
|
42
|
+
* here are bootstrapped (events, repos, crons) but have no UI — they run
|
|
43
|
+
* silently in the background. Must contain at least `ui[activeDomain]`.
|
|
44
|
+
*/
|
|
10
45
|
ui: Record<string, ComponentType>;
|
|
46
|
+
/**
|
|
47
|
+
* Key of the domain whose UI is shown on launch. Must match one of the
|
|
48
|
+
* keys in `domains` AND have a corresponding entry in `ui`.
|
|
49
|
+
*/
|
|
11
50
|
activeDomain: string;
|
|
51
|
+
/**
|
|
52
|
+
* Additional React context providers to wrap around the app. Providers are
|
|
53
|
+
* applied in array order, outermost last — i.e. the last element in the
|
|
54
|
+
* array becomes the outermost wrapper and can observe all others.
|
|
55
|
+
*
|
|
56
|
+
* Example: `[ConfirmationProvider, ProcessToastProvider]`
|
|
57
|
+
*/
|
|
12
58
|
providers?: ProviderComponent[];
|
|
59
|
+
/** Light theme tokens. Defaults to the DS base theme if omitted. */
|
|
13
60
|
theme?: ThemeTokens;
|
|
61
|
+
/** Dark theme tokens. When provided, the system color-scheme toggle activates it. */
|
|
14
62
|
darkTheme?: ThemeTokens;
|
|
15
63
|
branding?: BrandingConfig;
|
|
16
|
-
/**
|
|
64
|
+
/**
|
|
65
|
+
* Brand typeface declared as semantic slots (df / bf / cf / mono).
|
|
66
|
+
* The app owns all font asset files; the framework wires them into DS roles.
|
|
67
|
+
* Omitting a slot keeps the DS default (Inter for body, Fira Code for mono).
|
|
68
|
+
*/
|
|
69
|
+
brandFont?: BrandFontConfig;
|
|
70
|
+
/** Component rendered while fonts + domain init complete. */
|
|
17
71
|
splashComponent?: ComponentType;
|
|
18
72
|
/** Minimum time (ms) the splash stays visible. Default 2000. */
|
|
19
73
|
splashDuration?: number;
|
|
@@ -5,7 +5,7 @@ import { ToastHost } from "../../infrastructure/ui/ToastHost";
|
|
|
5
5
|
import { setBranding } from "@sincpro/mobile-ui";
|
|
6
6
|
import { ToastProvider } from "@sincpro/mobile-ui/Feedback";
|
|
7
7
|
import { setActiveTheme, themeToVars, useTheme, } from "@sincpro/mobile-ui/theme";
|
|
8
|
-
import {
|
|
8
|
+
import { configureFonts, useFonts } from "@sincpro/mobile-ui/theme/typography";
|
|
9
9
|
import * as SplashScreen from "expo-splash-screen";
|
|
10
10
|
import { vars } from "nativewind";
|
|
11
11
|
import { useEffect, useState } from "react";
|
|
@@ -15,6 +15,47 @@ import { KeyboardProvider } from "react-native-keyboard-controller";
|
|
|
15
15
|
import { initialWindowMetrics, SafeAreaProvider, SafeAreaView, } from "react-native-safe-area-context";
|
|
16
16
|
import { CommonProvider } from "./common_provider";
|
|
17
17
|
import { ActiveDomainApp, DomainSwitcherProvider } from "./domain_switcher";
|
|
18
|
+
/** Maps the semantic families object to the low-level DS FontRole record. */
|
|
19
|
+
function familiesToRoles(families) {
|
|
20
|
+
const roles = {};
|
|
21
|
+
const { display, body, caption, mono } = families;
|
|
22
|
+
if (display) {
|
|
23
|
+
if (display.regular)
|
|
24
|
+
roles.titleRegular = display.regular;
|
|
25
|
+
if (display.medium)
|
|
26
|
+
roles.titleMedium = display.medium;
|
|
27
|
+
if (display.bold)
|
|
28
|
+
roles.title = display.bold;
|
|
29
|
+
if (display.black)
|
|
30
|
+
roles.titleBlack = display.black;
|
|
31
|
+
}
|
|
32
|
+
if (body) {
|
|
33
|
+
if (body.light)
|
|
34
|
+
roles.light = body.light;
|
|
35
|
+
if (body.regular)
|
|
36
|
+
roles.regular = body.regular;
|
|
37
|
+
if (body.medium)
|
|
38
|
+
roles.medium = body.medium;
|
|
39
|
+
if (body.semiBold)
|
|
40
|
+
roles.semiBold = body.semiBold;
|
|
41
|
+
if (body.extraBold)
|
|
42
|
+
roles.extraBold = body.extraBold;
|
|
43
|
+
}
|
|
44
|
+
if (caption) {
|
|
45
|
+
if (caption.regular)
|
|
46
|
+
roles.mono = caption.regular;
|
|
47
|
+
if (caption.medium)
|
|
48
|
+
roles.monoMedium = caption.medium;
|
|
49
|
+
}
|
|
50
|
+
if (mono) {
|
|
51
|
+
if (mono.regular)
|
|
52
|
+
roles.mono = mono.regular;
|
|
53
|
+
if (mono.medium)
|
|
54
|
+
roles.monoMedium = mono.medium;
|
|
55
|
+
}
|
|
56
|
+
return roles;
|
|
57
|
+
}
|
|
58
|
+
/** Wraps children in the provider list. reduceRight → first item = innermost wrapper. */
|
|
18
59
|
function withProviders(providers, children) {
|
|
19
60
|
return providers.reduceRight((acc, Provider) => _jsx(Provider, { children: acc }), children);
|
|
20
61
|
}
|
|
@@ -33,10 +74,14 @@ export function createAppShell(config) {
|
|
|
33
74
|
if (config.branding) {
|
|
34
75
|
setBranding(config.branding);
|
|
35
76
|
}
|
|
77
|
+
// Wire brand font roles into the DS before first render.
|
|
78
|
+
if (config.brandFont?.families) {
|
|
79
|
+
configureFonts(familiesToRoles(config.brandFont.families));
|
|
80
|
+
}
|
|
36
81
|
return function AppShell() {
|
|
37
82
|
const [isReady, setIsReady] = useState(false);
|
|
38
83
|
const [minDurationMet, setMinDurationMet] = useState(false);
|
|
39
|
-
const
|
|
84
|
+
const [brandFontsLoaded] = useFonts(config.brandFont?.files ?? {});
|
|
40
85
|
const activeTheme = useTheme();
|
|
41
86
|
const themeStyle = vars(themeToVars(activeTheme));
|
|
42
87
|
const navTheme = {
|
|
@@ -61,11 +106,11 @@ export function createAppShell(config) {
|
|
|
61
106
|
return () => clearTimeout(t);
|
|
62
107
|
}, []);
|
|
63
108
|
useEffect(() => {
|
|
64
|
-
if (
|
|
109
|
+
if (brandFontsLoaded && isReady && minDurationMet) {
|
|
65
110
|
SplashScreen.hideAsync().catch(() => { });
|
|
66
111
|
}
|
|
67
|
-
}, [
|
|
68
|
-
if (!isReady || !
|
|
112
|
+
}, [brandFontsLoaded, isReady, minDurationMet]);
|
|
113
|
+
if (!isReady || !brandFontsLoaded || !minDurationMet) {
|
|
69
114
|
const Splash = config.splashComponent;
|
|
70
115
|
return Splash ? (_jsx(Splash, {})) : (_jsx(View, { style: { flex: 1, backgroundColor: activeTheme.bg.page } }));
|
|
71
116
|
}
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { type DeepPartial, type ThemeTokens } from "@sincpro/mobile-ui/theme";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* Build a fully-typed theme from a partial override object.
|
|
4
|
+
* Starts from the DS base theme and deep-merges your tokens on top.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* theme: createTheme({ primary: "#FF6600", bg: { page: "#
|
|
8
|
-
*
|
|
6
|
+
* createAppShell({
|
|
7
|
+
* theme: createTheme({ primary: "#FF6600", bg: { page: "#F5F5F5" } }),
|
|
8
|
+
* darkTheme: createTheme({ bg: { page: "#0A0A0A" }, primary: "#F5F5F5" }),
|
|
9
|
+
* ...
|
|
9
10
|
* });
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
12
|
+
* The result is injected once at bootstrap (static, not swapped at runtime)
|
|
13
|
+
* and propagates as both NativeWind CSS vars (`className`) and inline style props.
|
|
13
14
|
*/
|
|
14
15
|
export declare function createTheme(overrides?: DeepPartial<ThemeTokens>): ThemeTokens;
|
|
15
16
|
export type { DeepPartial, ThemeTokens };
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
import { extendTheme } from "@sincpro/mobile-ui/theme";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
3
|
+
* Build a fully-typed theme from a partial override object.
|
|
4
|
+
* Starts from the DS base theme and deep-merges your tokens on top.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
* theme: createTheme({ primary: "#FF6600", bg: { page: "#
|
|
8
|
-
*
|
|
6
|
+
* createAppShell({
|
|
7
|
+
* theme: createTheme({ primary: "#FF6600", bg: { page: "#F5F5F5" } }),
|
|
8
|
+
* darkTheme: createTheme({ bg: { page: "#0A0A0A" }, primary: "#F5F5F5" }),
|
|
9
|
+
* ...
|
|
9
10
|
* });
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
12
|
+
* The result is injected once at bootstrap (static, not swapped at runtime)
|
|
13
|
+
* and propagates as both NativeWind CSS vars (`className`) and inline style props.
|
|
13
14
|
*/
|
|
14
15
|
export function createTheme(overrides) {
|
|
15
16
|
return extendTheme(overrides);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sincpro/mobile",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
4
4
|
"description": "Sincpro Mobile framework core: offline-first infra (DB, queue/events, cron), DDD patterns, adapters, AppShell and module-driven composition for React Native",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"types": "./dist/index.d.ts",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"@react-navigation/native": ">=7",
|
|
62
62
|
"@react-navigation/native-stack": ">=7",
|
|
63
63
|
"react-native-screens": "*",
|
|
64
|
-
"@sincpro/mobile-ui": "^0.7.
|
|
64
|
+
"@sincpro/mobile-ui": "^0.7.1"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
67
|
"@types/big.js": "^6.2.2",
|
|
@@ -103,7 +103,7 @@
|
|
|
103
103
|
"react-native-screens": "~4.25.2",
|
|
104
104
|
"tsc-alias": "^1.8.17",
|
|
105
105
|
"typescript": "~5.9.2",
|
|
106
|
-
"@sincpro/mobile-ui": "^0.7.
|
|
106
|
+
"@sincpro/mobile-ui": "^0.7.1"
|
|
107
107
|
},
|
|
108
108
|
"exports": {
|
|
109
109
|
".": "./dist/index.js",
|