pikd_flutter_ar 0.8.0-beta.5
pikd_flutter_ar: ^0.8.0-beta.5 copied to clipboard
Flutter bridge for the PIKD native AR SDK, providing ARKit and ARCore sessions, geospatial placement, navigation, interactions, and physics.
example/lib/main.dart
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:local_auth_android/local_auth_android.dart';
import 'package:pikd_flutter_api/api.dart';
import 'package:pikd_flutter_ar/pikd_flutter_ar.dart';
/// **Minimal** Tier-0 AR-collect demo for the AR binding on its own: anonymous,
/// SDK-key only, no partner backend, no app chrome.
///
/// This is deliberately bare — camera, drops, tap-to-collect, one status line.
/// **For the full product demo** (all five prebuilt module screens, live↔sample
/// toggle, brand rebranding, Explore → AR collect) run the top-level **`demo/`**
/// app instead: `cd demo && flutter run`. Picking the wrong one is easy, so this
/// screen labels itself on-device too.
///
/// Shows the whole slice composed: the generated `pikd_flutter_api` data client
/// fetches/claims collectibles over `/sdk/v1`, and `pikd_flutter_ar` renders +
/// places them in AR. Runs on a physical device (ARKit/ARCore); analyzes clean
/// without one.
void main() => runApp(const DropDemoApp());
// Provisioned per environment: --dart-define=PIKD_SDK_KEY=... --dart-define=PIKD_BASE=...
const _base = String.fromEnvironment('PIKD_BASE',
defaultValue: 'https://api.pikd.app/sdk/v1');
const _sdkKey = String.fromEnvironment('PIKD_SDK_KEY', defaultValue: 'pk_demo');
class DropDemoApp extends StatelessWidget {
const DropDemoApp({super.key});
@override
Widget build(BuildContext context) =>
const MaterialApp(title: 'PIKD Drop Demo', home: DropDemoScreen());
}
class DropDemoScreen extends StatefulWidget {
const DropDemoScreen({super.key});
@override
State<DropDemoScreen> createState() => _DropDemoScreenState();
}
class _DropDemoScreenState extends State<DropDemoScreen> {
late final CollectiblesApi _api;
String _status = 'starting…';
String _biometricStatus = 'not checked';
bool _arReady =
false; // gates PikdArView: only mount after permissions granted
Completer<void>?
_sessionStarted; // completes on ArSessionStarted (see _start)
// Location recipe (ADR 0005): the SDK core is plugin-free — the host provides
// location. Here we wire `geolocator` behind PIKD's CallbackLocationProvider;
// with PikdSdk you'd pass this as `PikdProviders(location: ...)`.
final LocationProvider _location = CallbackLocationProvider(() async {
if (!await Geolocator.isLocationServiceEnabled()) return null;
var perm = await Geolocator.checkPermission();
if (perm == LocationPermission.denied) {
perm = await Geolocator.requestPermission();
}
if (perm == LocationPermission.denied ||
perm == LocationPermission.deniedForever) {
return null;
}
final p = await Geolocator.getCurrentPosition();
return (lat: p.latitude, lng: p.longitude);
});
@override
void initState() {
super.initState();
final client = ApiClient(basePath: _base);
// Tier-0: SDK key identifies the tenant; the user is anonymous.
client.addDefaultHeader('x-pikd-sdk-key', _sdkKey);
_api = CollectiblesApi(client);
_start();
}
Future<void> _start() async {
try {
// Android needs an explicit runtime grant before the AR session/view can
// start (iOS prompts at point-of-use). Gate on the grant, then mount the
// view BEFORE initialize()/startArSession(): PikdArContainerView only
// auto-starts its renderer when it attaches while the SDK is
// NOT_INITIALIZED/INITIALIZED. If the session is already RUNNING when it
// attaches, it no-ops and the camera never binds (black view).
if (!await PikdAr.requestArPermissions()) {
if (mounted) {
setState(() => _status = 'camera + location permission required');
}
return;
}
if (mounted) setState(() => _arReady = true);
await PikdAr.initialize(const PikdArConfig(
userId: 'anon',
apiBaseUrl: _base,
authToken: _sdkKey,
mockGps: true,
));
// Subscribe BEFORE starting so the session-started event can't be missed.
PikdAr.events.listen(_onEvent);
// Don't start the session here — PikdArView owns it, auto-starting on
// window attach on both platforms. Starting it here too is a real
// double-start: both land in ARManager.runSession, which holds ONE pending
// completion, so the view's (completion-less) start replaces ours and
// nothing ever completes it — an await hangs forever, and the duplicate
// start leaves native session state inconsistent across close/reopen.
// Wait for the event the winning start emits instead.
_sessionStarted = Completer<void>();
// Already-running session emits no new event, so time out and continue.
await _sessionStarted!.future.timeout(
const Duration(seconds: 8),
onTimeout: () =>
debugPrint('[example] no ArSessionStarted in 8s — continuing'),
);
// Query drops around the device's real location (via the provider above);
// fall back to a demo location if it's unavailable.
final here =
await _location.currentLocation() ?? (lat: 51.5074, lng: -0.1278);
if (mounted) {
setState(() => _status =
'near ${here.lat.toStringAsFixed(4)}, ${here.lng.toStringAsFixed(4)} — finding drops…');
}
final drops =
await _api.getNearbyCollectibles(here.lat, here.lng, radiusM: 250) ??
const [];
for (final d in drops) {
// Pick the platform-appropriate 3D model (iOS renders USDZ, Android GLB).
// Skip drops with no model for this platform — nothing to render.
final modelUrl = defaultTargetPlatform == TargetPlatform.iOS
? d.iosUrl
: d.androidUrl;
if (modelUrl == null || modelUrl.isEmpty) continue;
final asset = await PikdAr.loadAsset(modelUrl, d.id);
await PikdAr.placeAssetOnSurface(asset.id);
// Auto-play the model's first clip, as RN's <ARAsset> does. Only works
// AFTER placement (native needs an instance to animate) and needs a
// settle delay because placement finishes asynchronously — playing
// immediately silently does nothing. Unawaited so one asset's delay
// doesn't stall placing the rest.
_playFirstAnimation(asset);
}
if (mounted) {
setState(
() => _status = '${drops.length} drops nearby — tap to collect');
}
} catch (e) {
if (mounted) setState(() => _status = 'error: $e');
}
}
/// Play the model's first animation clip, looped (mirrors RN's `<ARAsset>`
/// auto-play, which uses `asset.animations[0]` when no clip is named).
Future<void> _playFirstAnimation(Asset asset) async {
if (asset.animations.isEmpty) return;
await Future<void>.delayed(const Duration(milliseconds: 1200));
if (!mounted) return;
try {
await PikdAr.playAssetAnimation(asset.id, asset.animations.first,
loop: true);
} catch (e) {
debugPrint('[example] playAssetAnimation failed: $e');
}
}
void _onEvent(ArEvent e) {
if (e is ArSessionStarted && _sessionStarted?.isCompleted == false) {
_sessionStarted!.complete();
return;
}
if (e is Interaction && e.assetId != null) _collect(e.assetId!);
}
Future<void> _collect(String id) async {
final res = await _api.collectCollectible(id); // anonymous: no x-pikd-user
if (mounted) setState(() => _status = 'collected $id → ${res?.status}');
}
/// Android regression fixture for FragmentActivity-based host integrations.
///
/// The package itself has no biometric dependency. This Android-only fixture
/// proves that PIKD AR does not replace the AndroidX owners required by the
/// host biometric prompt.
Future<void> _verifyBiometrics() async {
final auth = LocalAuthAndroid();
try {
final supported = await auth.isDeviceSupported();
if (!supported) {
if (mounted) {
setState(() => _biometricStatus = 'not supported on this device');
}
return;
}
final authenticated = await auth.authenticate(
localizedReason: 'Verify biometric authentication alongside PIKD AR.',
authMessages: const [],
options: const AuthenticationOptions(
biometricOnly: true,
stickyAuth: true,
),
);
if (mounted) {
setState(() {
_biometricStatus = authenticated
? 'authentication succeeded'
: 'authentication cancelled';
});
}
} catch (error) {
if (mounted) {
setState(() => _biometricStatus = 'authentication failed: $error');
}
}
}
@override
void dispose() {
PikdAr.stopArSession();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
if (_arReady)
const PikdArView()
else
const ColoredBox(color: Colors.black),
Positioned(
top: 60,
left: 16,
right: 16,
child: Text(
_status,
style: const TextStyle(color: Colors.white, fontSize: 16),
),
),
if (defaultTargetPlatform == TargetPlatform.android)
Positioned(
top: 100,
left: 16,
child: FilledButton.tonalIcon(
onPressed: _verifyBiometrics,
icon: const Icon(Icons.fingerprint),
label: Text('Biometric check: $_biometricStatus'),
),
),
// Self-labelling: there are two runnable apps in this repo and landing
// on the wrong one costs real time. Say which this is, on-device.
const Positioned(
bottom: 24,
left: 16,
right: 16,
child: Text(
'minimal AR-binding demo — for the full app (all modules, '
'live/sample + brand toggles) run demo/ at the repo root',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.white70, fontSize: 11),
),
),
],
),
);
}
}