c2pa_flutter 0.1.0
c2pa_flutter: ^0.1.0 copied to clipboard
Combined read/write C2PA Flutter plugin with manifest signing and certificate management. Uses the official c2pa-rs Rust library via FFI.
example/lib/main.dart
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import 'package:c2pa_flutter/c2pa_flutter.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:image/image.dart' as img;
import 'package:path_provider/path_provider.dart';
/// A small 100x100 steel-blue JPEG used when bundled assets are missing.
final Uint8List _fallbackJpeg = base64Decode(
'/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDABALDA4MChAODQ4SERATGCgaGBYWGDEjJR0o'
'OjM9PDkzODdASFxOQERXRTc4UG1RV19iZ2hnPk1xeXBkeFxlZ2P/2wBDARESEhgVGC8a'
'Gi9jQjhCY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2Nj'
'Y2NjY2P/wAARCABkAGQDASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcI'
'CQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS'
'0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1'
'dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW'
'19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcI'
'CQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMz'
'UvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0'
'dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU'
'1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwCKiiivQPPCiiigAooooAKK'
'KKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAo'
'oooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKAC'
'iiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooAKKKKACiiigAooooA'
'KKKKACiiigAooooAKKKKAP/9k=',
);
// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await C2pa.init();
runApp(const C2paExampleApp());
}
// ---------------------------------------------------------------------------
// App shell
// ---------------------------------------------------------------------------
class C2paExampleApp extends StatelessWidget {
const C2paExampleApp({super.key});
@override
Widget build(final BuildContext context) => MaterialApp(
title: 'C2PA Flutter Example',
theme: ThemeData(
colorSchemeSeed: Colors.indigo,
useMaterial3: true,
),
home: const HomePage(),
);
}
// ---------------------------------------------------------------------------
// Home – two tabs: Read / Write
// ---------------------------------------------------------------------------
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
int _tabIndex = 0;
/// Path to the last image produced by the Write tab (signed or edited).
/// Shared with the Read tab so users can inspect it there.
File? _lastWrittenFile;
void _onImageWritten(final File file) =>
setState(() => _lastWrittenFile = file);
@override
Widget build(final BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('C2PA Flutter')),
body: IndexedStack(
index: _tabIndex,
children: [
ReadDemoTab(lastWrittenFile: _lastWrittenFile),
WriteDemoTab(onImageWritten: _onImageWritten),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _tabIndex,
onDestinationSelected: (final i) => setState(() => _tabIndex = i),
destinations: const [
NavigationDestination(
icon: Icon(Icons.search),
label: 'Read',
),
NavigationDestination(
icon: Icon(Icons.edit),
label: 'Write',
),
],
),
);
}
// ---------------------------------------------------------------------------
// READ DEMO
// ---------------------------------------------------------------------------
class ReadDemoTab extends StatefulWidget {
const ReadDemoTab({super.key, this.lastWrittenFile});
/// If non-null, the Write tab has produced a signed/edited image the user
/// can inspect here.
final File? lastWrittenFile;
@override
State<ReadDemoTab> createState() => _ReadDemoTabState();
}
/// Bundled C2PA test images — each has a different manifest structure.
const _testImages = [
(asset: 'assets/c2pa_test.jpg', label: 'Claim + Assertion (CA)'),
(asset: 'assets/c2pa_claim_only.jpg', label: 'Claim Only (C)'),
(asset: 'assets/c2pa_chained.jpg', label: 'Chained Manifests (CACA)'),
(asset: 'assets/c2pa_ingredient.jpg', label: 'Ingredient Reference (CICA)'),
(asset: 'assets/c2pa_redacted.jpg', label: 'Redacted Manifest (XCA)'),
];
class _ReadDemoTabState extends State<ReadDemoTab>
with AutomaticKeepAliveClientMixin {
bool _loading = false;
String? _error;
String? _info;
ManifestStore? _manifestStore;
File? _imageFile;
int _imageIndex = 0;
@override
bool get wantKeepAlive => true;
Future<void> _loadTestImage() async {
setState(() {
_loading = true;
_error = null;
_info = null;
});
try {
final current = _testImages[_imageIndex];
// Load the bundled C2PA test image
Uint8List imageBytes;
try {
final byteData = await rootBundle.load(current.asset);
imageBytes = byteData.buffer.asUint8List();
debugPrint('Loaded "${current.label}" '
'(${imageBytes.length} bytes)');
} catch (e) {
debugPrint('Bundled asset not found: $e');
imageBytes = _fallbackJpeg;
setState(() {
_info = 'Bundled asset not found — '
'loaded a plain fallback image with no manifest.';
});
}
// Save to temp file (the reader needs a file path)
final tempDir = await getTemporaryDirectory();
final file = File('${tempDir.path}/c2pa_test_$_imageIndex.jpg');
await file.writeAsBytes(imageBytes);
// Read the manifest
final reader = C2pa.reader();
ManifestStore? store;
try {
store = reader.readFromFile(file.path);
} catch (e) {
debugPrint('Manifest read error: $e');
}
// Advance the index for next tap (wraps around)
_imageIndex = (_imageIndex + 1) % _testImages.length;
setState(() {
_imageFile = file;
_manifestStore = store;
_loading = false;
});
} catch (e) {
setState(() {
_error = e.toString();
_loading = false;
});
}
}
/// Load an image produced by the Write tab.
Future<void> _loadWrittenImage(final File file) async {
setState(() {
_loading = true;
_error = null;
_info = null;
});
try {
final reader = C2pa.reader();
ManifestStore? store;
try {
store = reader.readFromFile(file.path);
} catch (e) {
debugPrint('Manifest read error: $e');
}
setState(() {
_imageFile = file;
_manifestStore = store;
_loading = false;
_info = 'Loaded signed image from Write tab.';
});
} catch (e) {
setState(() {
_error = e.toString();
_loading = false;
});
}
}
@override
Widget build(final BuildContext context) {
super.build(context);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Read C2PA manifests from signed images. Tap the button '
'to cycle through ${_testImages.length} bundled test images, '
'each with a different manifest structure.',
style: Theme.of(context).textTheme.bodyLarge,
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _loading ? null : _loadTestImage,
icon: const Icon(Icons.image_search),
label: Text(
_loading
? 'Loading...'
: 'Load: ${_testImages[_imageIndex].label} '
'(${_imageIndex + 1}/${_testImages.length})',
),
),
if (widget.lastWrittenFile != null) ...[
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed:
_loading ? null : () => _loadWrittenImage(widget.lastWrittenFile!),
icon: const Icon(Icons.history),
label: const Text('Load Last Signed Image'),
),
],
if (_info != null) ...[
const SizedBox(height: 16),
Card(
color: Theme.of(context).colorScheme.secondaryContainer,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
const Icon(Icons.info_outline, size: 20),
const SizedBox(width: 8),
Expanded(child: Text(_info!)),
],
),
),
),
],
if (_error != null) ...[
const SizedBox(height: 16),
Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
if (_imageFile != null) ...[
const SizedBox(height: 16),
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
_imageFile!,
height: 200,
fit: BoxFit.cover,
errorBuilder:
(final context, final error, final stackTrace) =>
Container(
height: 200,
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(12),
),
child: const Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.image_not_supported, size: 48),
SizedBox(height: 8),
Text('Image preview unavailable'),
],
),
),
),
),
),
],
if (_manifestStore != null) ...[
const SizedBox(height: 16),
_ManifestCard(
manifestStore: _manifestStore!,
initiallyExpanded: true,
),
] else if (_imageFile != null && !_loading) ...[
const SizedBox(height: 16),
const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Column(
children: [
Icon(Icons.shield_outlined, size: 48, color: Colors.grey),
SizedBox(height: 8),
Text(
'No C2PA manifest found',
style: TextStyle(fontWeight: FontWeight.w600),
),
SizedBox(height: 4),
Text(
'This image does not contain Content Credentials. '
'Use the Write tab to create and sign a manifest.',
textAlign: TextAlign.center,
),
],
),
),
),
],
],
),
);
}
}
// ---------------------------------------------------------------------------
// Manifest display card
// ---------------------------------------------------------------------------
class _ManifestCard extends StatelessWidget {
const _ManifestCard({
required this.manifestStore,
this.initiallyExpanded = false,
});
final ManifestStore manifestStore;
final bool initiallyExpanded;
@override
Widget build(final BuildContext context) {
final manifest = manifestStore.active;
if (manifest == null) {
return const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text('No active manifest found.'),
),
);
}
// Build a compact subtitle: issuer, algorithm, action count
final parts = <String>[
if (manifest.signatureInfo?.issuer != null)
manifest.signatureInfo!.issuer!,
if (manifest.signatureInfo?.alg != null) manifest.signatureInfo!.alg!,
if (manifest.actions != null && manifest.actions!.isNotEmpty)
'${manifest.actions!.length} action(s)',
if (manifest.ingredients.isNotEmpty)
'${manifest.ingredients.length} ingredient(s)',
];
return Card(
clipBehavior: Clip.antiAlias,
child: ExpansionTile(
initiallyExpanded: initiallyExpanded,
leading: const Icon(Icons.description_outlined),
title: Text(
manifest.title ?? 'Manifest',
style: const TextStyle(fontWeight: FontWeight.w600),
),
subtitle: parts.isNotEmpty
? Text(parts.join(' \u2022 '),
style: Theme.of(context).textTheme.bodySmall)
: null,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Divider(),
_row('Title', manifest.title),
_row('Format', manifest.format),
_row('Claim Generator', manifest.claimGenerator),
_row('Label', manifest.label),
if (manifest.signatureInfo != null) ...[
const SizedBox(height: 8),
Text(
'Signature',
style: Theme.of(context).textTheme.titleSmall,
),
_row('Issuer', manifest.signatureInfo!.issuer),
_row('Algorithm', manifest.signatureInfo!.alg),
_row('Time', manifest.signatureInfo!.time),
],
if (manifest.ingredients.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Ingredients (${manifest.ingredients.length})',
style: Theme.of(context).textTheme.titleSmall,
),
for (final ing in manifest.ingredients)
_row(ing.title ?? 'untitled', ing.relationship),
],
if (manifest.actions != null &&
manifest.actions!.isNotEmpty) ...[
const SizedBox(height: 8),
Text(
'Actions (${manifest.actions!.length})',
style: Theme.of(context).textTheme.titleSmall,
),
for (final action in manifest.actions!)
_row(action.action, action.description ?? action.when),
],
if (manifest.hasAiGeneratedContent) ...[
const SizedBox(height: 8),
Chip(
avatar: const Icon(Icons.auto_awesome, size: 18),
label: const Text('Contains AI-generated content'),
backgroundColor:
Theme.of(context).colorScheme.tertiaryContainer,
),
],
if (manifest.trainingMining != null) ...[
const SizedBox(height: 8),
Text(
'Training & Data Mining',
style: Theme.of(context).textTheme.titleSmall,
),
_row(
'AI Training',
manifest.trainingMining!.trainingAllowed == true
? 'Allowed'
: 'Not Allowed',
),
_row(
'Data Mining',
manifest.trainingMining!.dataMiningAllowed == true
? 'Allowed'
: 'Not Allowed',
),
],
const SizedBox(height: 8),
Text(
'Total manifests in store: '
'${manifestStore.manifests.length}',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
],
),
);
}
Widget _row(final String label, final String? value) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
label,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
Expanded(
child: Text(
value ?? '\u2014',
style: const TextStyle(color: Colors.black87),
),
),
],
),
);
}
// ---------------------------------------------------------------------------
// WRITE DEMO
// ---------------------------------------------------------------------------
class WriteDemoTab extends StatefulWidget {
const WriteDemoTab({super.key, this.onImageWritten});
/// Called whenever the Write tab produces a new signed/edited image file.
final ValueChanged<File>? onImageWritten;
@override
State<WriteDemoTab> createState() => _WriteDemoTabState();
}
class _WriteDemoTabState extends State<WriteDemoTab>
with AutomaticKeepAliveClientMixin {
// -- Step 1: Initial signing --
bool _signing = false;
String? _error;
ManifestStore? _signedManifestStore;
File? _signedImageFile;
Uint8List? _signedBytes; // keep raw bytes for re-editing
// -- Step 2: Edit & re-sign --
double _brightness = 0.0; // –1.0 … +1.0
bool _resigning = false;
ManifestStore? _editedManifestStore;
File? _editedImageFile;
Uint8List? _editedImageBytes; // for cache-proof preview
@override
bool get wantKeepAlive => true;
// ---------- helpers ----------
Future<FileSigner> _loadSigner() async {
final certData = await rootBundle.load('assets/test_es256_cert.pem');
final keyData = await rootBundle.load('assets/test_es256.pem');
return FileSigner(
privateKeyPem: keyData.buffer.asUint8List(),
certChainPem: certData.buffer.asUint8List(),
algorithm: SigningAlgorithm.es256,
);
}
/// Apply brightness adjustment to JPEG bytes using the `image` package.
Uint8List _applyBrightness(final Uint8List jpeg, final double amount) {
final decoded = img.decodeJpg(jpeg);
if (decoded == null) return jpeg;
// adjustColor brightness is a multiplier: 1.0 = unchanged, 0 = black, 2 = 2x.
// Our slider is –1.0 … +1.0, so map to 0.0 … 2.0.
final adjusted = img.adjustColor(decoded, brightness: 1.0 + amount);
return Uint8List.fromList(img.encodeJpg(adjusted, quality: 92));
}
// ---------- Step 1: sign the original ----------
Future<void> _signDemo() async {
setState(() {
_signing = true;
_error = null;
_signedManifestStore = null;
_signedImageFile = null;
_signedBytes = null;
// Reset edit state too
_brightness = 0.0;
_editedManifestStore = null;
_editedImageFile = null;
_editedImageBytes = null;
});
try {
final byteData = await rootBundle.load('assets/c2pa_test.jpg');
final sourceBytes = byteData.buffer.asUint8List();
final signer = await _loadSigner();
final manifest = ManifestBuilder(
claimGenerator: 'c2pa_flutter_example/1.0',
title: 'demo_signed.jpg',
format: 'image/jpeg',
)
.addAction(
C2paActions.created(
description: 'Signed by c2pa_flutter example app',
),
)
.build();
final writer = C2pa.writer();
final signedResult = await writer.sign(
imageBytes: sourceBytes,
mimeType: 'image/jpeg',
manifest: manifest,
signer: signer,
);
final signedBytes = Uint8List.fromList(signedResult);
final tempDir = await getTemporaryDirectory();
final signedFile = File('${tempDir.path}/demo_signed.jpg');
await signedFile.writeAsBytes(signedBytes);
final reader = C2pa.reader();
final store = reader.readFromFile(signedFile.path);
widget.onImageWritten?.call(signedFile);
setState(() {
_signedBytes = signedBytes;
_signedImageFile = signedFile;
_signedManifestStore = store;
_signing = false;
});
} catch (e) {
setState(() {
_error = e.toString();
_signing = false;
});
}
}
// ---------- Step 2: edit & re-sign ----------
Future<void> _editAndResign() async {
if (_signedBytes == null) return;
setState(() {
_resigning = true;
_error = null;
_editedManifestStore = null;
_editedImageFile = null;
_editedImageBytes = null;
});
try {
// 1. Apply the brightness filter to the signed image bytes
final editedBytes = _applyBrightness(_signedBytes!, _brightness);
// 2. Build a new manifest with an "edited" action describing the change
final brightnessPercent = (_brightness * 100).round();
final signer = await _loadSigner();
final manifest = ManifestBuilder(
claimGenerator: 'c2pa_flutter_example/1.0',
title: 'demo_edited.jpg',
format: 'image/jpeg',
)
.addAction(
C2paActions.edited(
description:
'Brightness adjusted ${brightnessPercent > 0 ? '+' : ''}'
'$brightnessPercent% in c2pa_flutter example app',
),
)
.build();
// 3. Sign the edited image
final writer = C2pa.writer();
final resignedResult = await writer.sign(
imageBytes: editedBytes,
mimeType: 'image/jpeg',
manifest: manifest,
signer: signer,
);
final resignedBytes = Uint8List.fromList(resignedResult);
// 4. Save and read back to verify
final tempDir = await getTemporaryDirectory();
final editedFile = File('${tempDir.path}/demo_edited.jpg');
await editedFile.writeAsBytes(resignedBytes);
final reader = C2pa.reader();
final store = reader.readFromFile(editedFile.path);
widget.onImageWritten?.call(editedFile);
setState(() {
_editedImageFile = editedFile;
_editedImageBytes = resignedBytes;
_editedManifestStore = store;
_resigning = false;
});
} catch (e) {
setState(() {
_error = e.toString();
_resigning = false;
});
}
}
// ---------- build ----------
@override
Widget build(final BuildContext context) {
super.build(context);
final theme = Theme.of(context);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Intro text
Text(
'Sign an image with a C2PA manifest, then adjust brightness '
'and re-sign to demonstrate provenance tracking.',
style: theme.textTheme.bodyLarge,
),
const SizedBox(height: 16),
// ── Step 1: Sign ──
FilledButton.icon(
onPressed: _signing ? null : _signDemo,
icon: const Icon(Icons.draw),
label: Text(_signing ? 'Signing...' : 'Sign Image'),
),
// Error banner
if (_error != null) ...[
const SizedBox(height: 16),
Card(
color: theme.colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Icon(Icons.error_outline,
size: 20, color: theme.colorScheme.error),
const SizedBox(width: 8),
Expanded(
child: Text(_error!,
style: TextStyle(color: theme.colorScheme.error)),
),
],
),
),
),
],
// ── Single image preview ──
// Shows: signed image → filtered preview while editing → final result
if (_signedImageFile != null) ...[
const SizedBox(height: 16),
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: _editedImageBytes != null
// After re-sign: show the actual saved result
? Image.memory(_editedImageBytes!,
height: 200, fit: BoxFit.cover)
: _signedBytes != null && _brightness != 0.0
// While adjusting: show live filtered preview
? ColorFiltered(
colorFilter: ColorFilter.matrix(
_brightnessMatrix(_brightness)),
child: Image.file(_signedImageFile!,
height: 200, fit: BoxFit.cover),
)
// Initial signed image
: Image.file(_signedImageFile!,
height: 200, fit: BoxFit.cover),
),
],
// ── Success banner + manifest for current step ──
if (_editedManifestStore != null) ...[
const SizedBox(height: 12),
Card(
color: theme.colorScheme.primaryContainer,
child: const Padding(
padding: EdgeInsets.all(12),
child: Row(
children: [
Icon(Icons.verified, size: 20),
SizedBox(width: 8),
Expanded(
child: Text(
'Edited image re-signed with tracked action!',
style: TextStyle(fontWeight: FontWeight.w600)),
),
],
),
),
),
const SizedBox(height: 8),
_ManifestCard(manifestStore: _editedManifestStore!),
] else if (_signedManifestStore != null) ...[
const SizedBox(height: 12),
Card(
color: theme.colorScheme.primaryContainer,
child: const Padding(
padding: EdgeInsets.all(12),
child: Row(
children: [
Icon(Icons.verified, size: 20),
SizedBox(width: 8),
Expanded(
child: Text('Image signed successfully!',
style: TextStyle(fontWeight: FontWeight.w600)),
),
],
),
),
),
const SizedBox(height: 8),
_ManifestCard(manifestStore: _signedManifestStore!),
],
// ── Edit controls (appear after signing) ──
if (_signedBytes != null) ...[
const SizedBox(height: 20),
const Divider(),
const SizedBox(height: 12),
Text('Edit & Re-sign', style: theme.textTheme.titleMedium),
const SizedBox(height: 12),
// Brightness slider
Row(
children: [
const Icon(Icons.brightness_low, size: 20),
Expanded(
child: Slider(
value: _brightness,
min: -1.0,
max: 1.0,
divisions: 20,
label: '${(_brightness * 100).round()}%',
onChanged: (final v) => setState(() {
_brightness = v;
// Clear previous edit result so preview goes live again
_editedImageBytes = null;
_editedManifestStore = null;
_editedImageFile = null;
}),
),
),
const Icon(Icons.brightness_high, size: 20),
],
),
Text(
'Brightness: ${(_brightness * 100).round()}%',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall,
),
const SizedBox(height: 12),
// Re-sign button
OutlinedButton.icon(
onPressed: _resigning ? null : _editAndResign,
icon: const Icon(Icons.auto_fix_high),
label: Text(
_resigning ? 'Re-signing...' : 'Apply Edit & Re-sign'),
),
],
const SizedBox(height: 16),
_codeSnippetCard(context),
],
),
);
}
/// Build a 4×5 colour matrix that adjusts brightness via scaling.
///
/// Uses a multiplicative scale factor (not an additive offset) so the
/// live preview matches the `image` package's `adjustColor(brightness:)`
/// which also multiplies each channel. [amount] ranges from –1.0 to +1.0;
/// 0 = unchanged. Maps to a scale of 0.0 … 2.0 (same as _applyBrightness).
List<double> _brightnessMatrix(final double amount) {
final s = 1.0 + amount; // scale factor: 0 = black, 1 = unchanged, 2 = 2x
return <double>[
s, 0, 0, 0, 0, //
0, s, 0, 0, 0,
0, 0, s, 0, 0,
0, 0, 0, 1, 0,
];
}
Widget _codeSnippetCard(final BuildContext context) => Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Example Code',
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'// Build a manifest\n'
'final manifest = ManifestBuilder(\n'
" claimGenerator: 'MyApp/1.0',\n"
" title: 'photo.jpg',\n"
" format: 'image/jpeg',\n"
')\n'
' .addAction(C2paActions.edited(\n'
" description: 'Brightness +50%',\n"
' ))\n'
' .build();\n'
'\n'
'// Sign with file-based keys\n'
'final signer = FileSigner(\n'
' privateKeyPem: keyBytes,\n'
' certChainPem: certBytes,\n'
' algorithm: SigningAlgorithm.es256,\n'
');\n'
'\n'
'final signedBytes = await writer.sign(\n'
' imageBytes: imageBytes,\n'
" mimeType: 'image/jpeg',\n"
' manifest: manifest,\n'
' signer: signer,\n'
');',
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
),
),
),
],
),
),
);
}