appattest_flutter 0.1.0
appattest_flutter: ^0.1.0 copied to clipboard
Flutter bridge for AppAttest — App-Attest-gated secret delivery for iOS.
// AppAttestExample — Flutter reference app (v4.12).
//
// Demonstrates the v4.12 ergonomics:
// - AppAttest.start() at app boot.
// - AppAttest.stateStream drives splash / ready / failure UI.
// - AppAttest.allSecrets() reads the populated dict.
//
// Before running:
// 1. Set your Apple Team in Xcode (ios/Runner.xcworkspace).
// 2. Change the bundle identifier if the default isn't available.
// 3. Add the App Attest capability via "+ Capability → App Attest".
// 4. Ask api to register your (teamId, bundleId) under the development
// environment with at least one secret.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:appattest_flutter/appattest_flutter.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// v7.3: SDK is bucket-blind + base URL hardcoded to edge.appattest.dev.
// No setup required beyond start().
await AppAttest.start();
runApp(const AppAttestApp());
}
class AppAttestApp extends StatelessWidget {
const AppAttestApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'AppAttest · Flutter',
theme: ThemeData(
useMaterial3: true,
colorSchemeSeed: const Color(0xFF3B82F6),
),
home: const HomeScreen(),
);
}
}
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
AppAttestState _state = AppAttestState(name: AppAttestStateName.initializing);
Map<String, String> _secrets = {};
bool _revealAll = false;
StreamSubscription<AppAttestState>? _stateSub;
@override
void initState() {
super.initState();
_bootstrap();
_stateSub = AppAttest.stateStream.listen((s) {
if (!mounted) return;
setState(() => _state = s);
if (s.name == AppAttestStateName.ready) _refreshSecrets();
});
}
Future<void> _bootstrap() async {
final s = await AppAttest.getState();
if (!mounted) return;
setState(() => _state = s);
if (s.name == AppAttestStateName.ready) _refreshSecrets();
}
Future<void> _refreshSecrets() async {
try {
final m = await AppAttest.allSecrets();
if (!mounted) return;
setState(() => _secrets = m);
} catch (_) {/* ignore */}
}
@override
void dispose() {
_stateSub?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF8FAFC),
appBar: AppBar(
title: const Text('AppAttest · Flutter',
style: TextStyle(fontWeight: FontWeight.w700)),
centerTitle: true,
backgroundColor: Colors.transparent,
elevation: 0,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_StateCard(state: _state),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => AppAttest.retry(),
child: const Text('Retry'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton(
onPressed: _secrets.isEmpty
? null
: () => setState(() => _revealAll = !_revealAll),
child: Text(_revealAll ? 'Hide secrets' : 'Show secrets'),
),
),
const SizedBox(width: 8),
Expanded(
child: OutlinedButton(
onPressed: () async {
await AppAttest.reset();
if (!mounted) return;
setState(() => _secrets = {});
},
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFFEF4444)),
child: const Text('Reset'),
),
),
],
),
const SizedBox(height: 8),
// Force fresh sync via invalidateBundle() — wipes the cached
// bundle and re-syncs without losing attestation. Exercises
// the bridge path added 2026-05-25 (RN/Cap/Flutter all wired
// through to AppAttestClient.invalidateBundle()).
OutlinedButton(
onPressed: _state.name == AppAttestStateName.ready
? () async {
await AppAttest.invalidateBundle();
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Force fresh sync — invalidateBundle() called'),
duration: Duration(seconds: 2),
),
);
}
: null,
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF3B82F6)),
child: const Text('Force fresh sync (uses 1 credit)'),
),
if (_state.name == AppAttestStateName.ready) ...[
const SizedBox(height: 16),
_SecretsPanel(secrets: _secrets, revealAll: _revealAll),
],
],
),
),
);
}
}
class _StateCard extends StatelessWidget {
const _StateCard({required this.state});
final AppAttestState state;
@override
Widget build(BuildContext context) {
final (color, label, detail) = switch (state.name) {
AppAttestStateName.initializing =>
(const Color(0xFF94A3B8), 'Initializing', 'Hydrating from Keychain'),
AppAttestStateName.attesting =>
(const Color(0xFF3B82F6), 'Attesting', 'Generating attestation with Apple'),
AppAttestStateName.syncing =>
(const Color(0xFF3B82F6), 'Syncing', 'Fetching secrets from api'),
AppAttestStateName.ready =>
(const Color(0xFF22C55E), 'Ready', 'Secrets cached.'),
AppAttestStateName.subscriptionRequired => (
const Color(0xFFF59E0B),
'Subscription required',
state.error?.message ?? 'Subscribe this project in the AppAttest dashboard'
),
AppAttestStateName.creditsRequired => (
const Color(0xFFF59E0B),
'Credits required',
state.error?.message ?? 'Top up the project balance'
),
AppAttestStateName.unavailable => (
const Color(0xFFEF4444),
'Unavailable',
state.error?.message ?? 'Service temporarily unavailable'
),
};
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color, width: 2),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
Text(detail,
style: const TextStyle(fontSize: 13, color: Color(0xFF475569))),
if (state.error?.actionUrl != null) ...[
const SizedBox(height: 4),
SelectableText(
'Open: ${state.error!.actionUrl!}',
style:
const TextStyle(fontSize: 11, fontFamily: 'Menlo', color: Color(0xFF64748B)),
),
],
],
),
);
}
}
class _SecretsPanel extends StatelessWidget {
const _SecretsPanel({required this.secrets, required this.revealAll});
final Map<String, String> secrets;
final bool revealAll;
@override
Widget build(BuildContext context) {
final names = secrets.keys.toList()..sort();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Secrets (${names.length})',
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700)),
const SizedBox(height: 4),
if (names.isEmpty)
const Padding(
padding: EdgeInsets.all(12),
child: Text('No secrets registered for this env.',
style: TextStyle(fontSize: 12, color: Color(0xFF64748B))),
)
else
...names.map((n) => _SecretRow(
name: n,
value: secrets[n]!,
revealed: revealAll,
)),
],
);
}
}
class _SecretRow extends StatefulWidget {
const _SecretRow({required this.name, required this.value, required this.revealed});
final String name;
final String value;
final bool revealed;
@override
State<_SecretRow> createState() => _SecretRowState();
}
class _SecretRowState extends State<_SecretRow> {
bool _localReveal = false;
@override
Widget build(BuildContext context) {
final visible = widget.revealed || _localReveal;
final masked = '•' * widget.value.length.clamp(0, 20);
return Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.name,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700)),
const SizedBox(height: 2),
SelectableText(
visible ? widget.value : masked,
style: const TextStyle(
fontSize: 12, fontFamily: 'Menlo', color: Color(0xFF64748B)),
),
],
),
),
OutlinedButton(
onPressed: () => setState(() => _localReveal = !_localReveal),
child: Text(visible ? 'Hide' : 'Show',
style: const TextStyle(fontSize: 12)),
),
],
),
);
}
}