one_viewr_flutter 0.2.0
one_viewr_flutter: ^0.2.0 copied to clipboard
Client Flutter embarquable pour Oneviewr CRM — identify/track/registerPushToken et source d'acquisition via une clé publique non secrète. Port direct de @oneviewr/js.
example/lib/main.dart
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:one_viewr_flutter/one_viewr_flutter.dart';
/// Application de démonstration de `one_viewr_flutter`.
///
/// C'est un formulaire unique, dans l'ordre où les commandes doivent être
/// appelées (`boot` → `identify` → `track`/`registerPushToken` → `reset`) —
/// pas un bac à sable : elle sert à vérifier sur un vrai réseau ce qu'aucun
/// test avec un faux serveur ne peut vérifier, contre un vrai backend
/// Oneviewr (local ou de production).
void main() => runApp(const OneViewrExampleApp());
/// Racine de la démonstration.
class OneViewrExampleApp extends StatelessWidget {
/// Construit l'application.
const OneViewrExampleApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
title: 'one_viewr_flutter — démonstration',
theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal)),
home: const DemoPage(),
);
}
/// Écran unique : un champ par paramètre, un bouton par commande.
class DemoPage extends StatefulWidget {
/// Construit la page.
const DemoPage({super.key});
@override
State<DemoPage> createState() => _DemoPageState();
}
class _DemoPageState extends State<DemoPage> {
final TextEditingController _publicKey = TextEditingController();
final TextEditingController _apiBase = TextEditingController();
OneViewrEnv _env = OneViewrEnv.local;
bool _booted = false;
final TextEditingController _externalId = TextEditingController(
text: 'demo-user-1',
);
final TextEditingController _email = TextEditingController();
final TextEditingController _phone = TextEditingController();
final TextEditingController _language = TextEditingController(text: 'fr');
final TextEditingController _firstName = TextEditingController();
final TextEditingController _lastName = TextEditingController();
// Champs métier d'`identify` (mêmes clés que le SDK JS), tous optionnels.
final TextEditingController _birthDate = TextEditingController();
final TextEditingController _country = TextEditingController();
final TextEditingController _region = TextEditingController();
final TextEditingController _city = TextEditingController();
final TextEditingController _district = TextEditingController();
final TextEditingController _accountValidatedAt = TextEditingController();
final TextEditingController _accountCreation = TextEditingController();
final TextEditingController _plan = TextEditingController();
final TextEditingController _traits = TextEditingController();
final TextEditingController _hash = TextEditingController();
bool _identified = false;
final TextEditingController _eventName = TextEditingController(
text: 'page_viewed',
);
final TextEditingController _pushToken = TextEditingController();
final TextEditingController _screen = TextEditingController(text: 'panier');
final TextEditingController _deepLink = TextEditingController(
text: 'https://app.exemple.com/offre?utm_source=meta&utm_campaign=123',
);
final List<String> _log = <String>[];
@override
void dispose() {
_publicKey.dispose();
_apiBase.dispose();
_externalId.dispose();
_email.dispose();
_phone.dispose();
_language.dispose();
_firstName.dispose();
_lastName.dispose();
_birthDate.dispose();
_country.dispose();
_region.dispose();
_city.dispose();
_district.dispose();
_accountValidatedAt.dispose();
_accountCreation.dispose();
_plan.dispose();
_traits.dispose();
_hash.dispose();
_eventName.dispose();
_pushToken.dispose();
_deepLink.dispose();
_screen.dispose();
super.dispose();
}
void _append(String message) {
setState(() => _log.insert(0, message));
}
static String _emptyToNull(String value) => value.trim();
/// Valeur saisie, ou `null` si le champ est vide : `identify` n'envoie
/// jamais un champ vide.
static String? _optional(TextEditingController controller) {
final value = controller.text.trim();
return value.isEmpty ? null : value;
}
/// `traits` saisi en JSON (objet), ou `null` si le champ est vide. Un JSON
/// invalide fait échouer `identify` avec un message dans le journal.
Map<String, dynamic>? _traitsValue() {
final raw = _traits.text.trim();
if (raw.isEmpty) return null;
final decoded = jsonDecode(raw);
if (decoded is! Map<String, dynamic>) {
throw const FormatException(
'traits doit être un objet JSON, ex. {"segment": "vip"}',
);
}
return decoded;
}
Future<void> _run(String label, Future<void> Function() action) async {
try {
await action();
_append('✅ $label');
} on Object catch (error) {
_append('❌ $label : $error');
}
}
Future<void> _boot() => _run('boot', () async {
await OneViewr.boot(
publicKey: _publicKey.text.trim(),
env: _env,
apiBase: _apiBase.text.trim().isEmpty ? null : _apiBase.text.trim(),
// Démonstrateur : lit le presse-papiers sur iOS pour exercer les liens
// d'acquisition différés (iOS 16 demande alors « Autoriser le collage ? »).
readIosClipboard: true,
// Valeur par défaut, écrite pour être vue : enregistre l'installation
// auprès d'Apple au premier lancement iOS. `false` si un autre SDK gère
// les valeurs de conversion. Clés à ajouter : ios/Runner/Info.plist.
appleAdAttribution: true,
);
setState(() => _booted = true);
});
Future<void> _identify() => _run('identify', () async {
final result = await OneViewr.identify(
externalId: _emptyToNull(_externalId.text).isEmpty
? null
: _externalId.text.trim(),
email: _emptyToNull(_email.text).isEmpty ? null : _email.text.trim(),
phone: _emptyToNull(_phone.text).isEmpty ? null : _phone.text.trim(),
language: _language.text.trim(),
firstName: _firstName.text.trim().isEmpty ? null : _firstName.text.trim(),
lastName: _lastName.text.trim().isEmpty ? null : _lastName.text.trim(),
birthDate: _optional(_birthDate),
country: _optional(_country),
region: _optional(_region),
city: _optional(_city),
district: _optional(_district),
accountValidatedAt: _optional(_accountValidatedAt),
accountCreation: _optional(_accountCreation),
plan: _optional(_plan),
traits: _traitsValue(),
hash: _optional(_hash),
);
setState(() => _identified = true);
_append(
'ℹ️ identify → externalId: ${result.externalId}, email: '
'${result.email}, phone: ${result.phone}',
);
});
Future<void> _track() => _run(
'track(${_eventName.text})',
() => OneViewr.track(_eventName.text.trim()),
);
// L'application réelle branche ici son propre paquet de géolocalisation.
// L'exemple ne demande aucune permission et ne lit aucune position.
Future<void> _sendTestLocation() => _run('trackLocation', () async {
final result = await OneViewr.trackLocation(
latitude: 4.05080,
longitude: 9.70450,
accuracyMeters: 18.5,
source: 'example',
);
_append(' → $result');
});
Future<void> _sendTestBatch() => _run('trackLocations(5)', () async {
final now = DateTime.now().toUtc();
final result = await OneViewr.trackLocations(
List<LocationSample>.generate(
5,
(index) => LocationSample(
latitude: 4.05080 + index * 0.0001,
longitude: 9.70450 + index * 0.0001,
accuracyMeters: 18.5,
occurredAt: now,
source: 'example',
),
),
);
_append(' → $result');
});
Future<void> _registerPushToken() => _run(
'registerPushToken',
() => OneViewr.registerPushToken(_pushToken.text.trim()),
);
Future<void> _unregisterPushToken() => _run(
'unregisterPushToken',
() => OneViewr.unregisterPushToken(_pushToken.text.trim()),
);
Future<void> _reset() => _run('reset', () async {
await OneViewr.reset();
setState(() => _identified = false);
});
// Écran courant pour les messages in-app à « Écrans ciblés » : avec le
// contexte, la vérification des messages repart aussitôt.
Future<void> _setScreen() => _run(
'setScreen(${_screen.text.trim()})',
() => OneViewr.setScreen(_screen.text, context: context),
);
// Simule un lien profond reçu par l'application : dans une vraie
// application, ce serait `AppLinks().uriLinkStream.listen(...)`.
Future<void> _recordDeepLink() => _run(
'recordDeepLink',
() => OneViewr.recordDeepLink(Uri.parse(_deepLink.text.trim())),
);
// Une vraie application passerait `link.appRoute` à son routeur ; ici on
// l'affiche dans le journal.
Future<void> _deferredDeepLink() => _run('deferredDeepLink', () async {
final link = await OneViewr.deferredDeepLink();
_append(
link == null
? ' → rien à remettre (installation organique, déjà remis…)'
: ' → ${link.status.name} ${link.code ?? ''} '
'route=${link.appRoute} params=${link.appParams}',
);
});
Future<void> _resolveDeepLink() => _run('resolveDeepLink', () async {
final link = await OneViewr.resolveDeepLink(
Uri.parse(_deepLink.text.trim()),
);
_append(
link == null
? ' → pas un lien d\'acquisition Oneviewr (ou serveur injoignable)'
: ' → ${link.status.name} route=${link.appRoute} '
'params=${link.appParams}',
);
});
// Bouton de test minimal, pas une fonctionnalité complète de cet exemple :
// il exerce `checkInAppMessages` contre un vrai backend (local ou de
// production), la seule chose qu'aucun test avec un faux serveur ne peut
// vérifier — le rendu WebView réel et le comportement de `url_launcher`
// sur un vrai appareil/simulateur.
Future<void> _checkInAppMessages() =>
_run('checkInAppMessages', () => OneViewr.checkInAppMessages(context));
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('one_viewr_flutter — démonstration')),
body: ListView(
padding: const EdgeInsets.all(16),
children: <Widget>[
_SectionTitle('1. boot — obligatoire avant tout le reste'),
_field(_publicKey, 'publicKey (pk_…, requis)'),
DropdownButtonFormField<OneViewrEnv>(
initialValue: _env,
decoration: const InputDecoration(
labelText: 'Environnement',
border: OutlineInputBorder(),
),
items: <DropdownMenuItem<OneViewrEnv>>[
for (final env in OneViewrEnv.values)
DropdownMenuItem<OneViewrEnv>(value: env, child: Text(env.name)),
],
onChanged: (value) => setState(() => _env = value ?? _env),
),
_field(_apiBase, 'apiBase (optionnel — prioritaire sur env)'),
const SizedBox(height: 8),
FilledButton(
onPressed: _publicKey.text.trim().isEmpty ? null : _boot,
child: const Text('boot()'),
),
const Divider(height: 32),
_SectionTitle('2. identify — au moins un identifiant + language'),
_field(_externalId, 'externalId'),
_field(_email, 'email'),
_field(_phone, 'phone'),
_field(_language, 'language (ISO 639-1, requis)'),
_field(_firstName, 'firstName'),
_field(_lastName, 'lastName'),
const SizedBox(height: 8),
Text(
'Champs métier — optionnels, rangés dans les attributs du contact',
style: Theme.of(context).textTheme.labelLarge,
),
_field(_birthDate, 'birthDate (AAAA-MM-JJ, ex. 1993-02-25)'),
_field(_country, 'country (code ISO, ex. CM)'),
_field(_region, 'region (ex. Littoral)'),
_field(_city, 'city (ex. Douala)'),
_field(_district, 'district (ex. Bonamoussadi)'),
_field(
_accountValidatedAt,
'accountValidatedAt (ex. 2026-09-14T10:00:00Z)',
),
_field(_accountCreation, 'accountCreation (ex. 2023-12-14T10:00:00Z)'),
_field(_plan, 'plan (ex. STANDARD)'),
_field(
_traits,
'traits — autres attributs en JSON, ex. {"segment": "vip"}',
),
_field(
_hash,
'hash — HMAC calculé par VOTRE serveur, si la vérification '
"d'identité est activée",
),
FilledButton(
onPressed: _booted ? _identify : null,
child: const Text('identify()'),
),
const Divider(height: 32),
_SectionTitle('3. track — nécessite un identify() réussi'),
_field(_eventName, 'eventName'),
FilledButton(
onPressed: _identified ? _track : null,
child: const Text('track()'),
),
const Divider(height: 32),
_SectionTitle('4. Positions — fournies par l’application hôte'),
FilledButton(
onPressed: _identified ? _sendTestLocation : null,
child: const Text('Envoyer une position de test (Douala)'),
),
OutlinedButton(
onPressed: _identified ? _sendTestBatch : null,
child: const Text('Envoyer un lot de 5'),
),
const Divider(height: 32),
_SectionTitle('5. registerPushToken / unregisterPushToken'),
_field(_pushToken, 'token FCM/APNs (obtenu par votre application)'),
Row(
children: <Widget>[
Expanded(
child: FilledButton(
onPressed: _identified ? _registerPushToken : null,
child: const Text('registerPushToken()'),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton(
onPressed: _booted ? _unregisterPushToken : null,
child: const Text('unregisterPushToken()'),
),
),
],
),
const Divider(height: 32),
_SectionTitle('6. checkInAppMessages — nécessite un identify() réussi'),
FilledButton(
onPressed: _identified ? _checkInAppMessages : null,
child: const Text('checkInAppMessages()'),
),
const Divider(height: 32),
_SectionTitle('7. reset — oublie l\'identité sur CET appareil'),
OutlinedButton(
onPressed: _booted ? _reset : null,
child: const Text('reset()'),
),
const Divider(height: 32),
_SectionTitle(
'8. recordDeepLink — source d\'acquisition (dernier contact, '
'envoyé avec le track() suivant)',
),
_field(_deepLink, 'lien reçu par l\'application'),
OutlinedButton(
onPressed: _booted ? _recordDeepLink : null,
child: const Text('recordDeepLink()'),
),
OutlinedButton(
onPressed: _booted ? _resolveDeepLink : null,
child: const Text('resolveDeepLink() — où mène ce lien ?'),
),
const _SectionTitle(
'7 bis. deferredDeepLink — le lien cliqué avant l\'installation '
'(une seule remise)',
),
OutlinedButton(
onPressed: _booted ? _deferredDeepLink : null,
child: const Text('deferredDeepLink()'),
),
const Divider(height: 32),
_SectionTitle(
'9. setScreen — écran courant (messages in-app à « Écrans '
'ciblés »)',
),
_field(
_screen,
'nom ou chemin de l\'écran, ex. panier ou /produits/42',
),
OutlinedButton(
onPressed: _booted ? _setScreen : null,
child: const Text('setScreen()'),
),
const Divider(height: 32),
const _SectionTitle('Journal'),
for (final line in _log)
Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text(line, style: const TextStyle(fontFamily: 'monospace')),
),
],
),
);
Widget _field(TextEditingController controller, String label) => Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: TextField(
controller: controller,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
),
),
);
}
class _SectionTitle extends StatelessWidget {
const _SectionTitle(this.text);
final String text;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(text, style: Theme.of(context).textTheme.titleMedium),
);
}