c2pa_flutter 0.1.0 copy "c2pa_flutter: ^0.1.0" to clipboard
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,
                  ),
                ),
              ),
            ],
          ),
        ),
      );
}
0
likes
160
points
80
downloads

Publisher

unverified uploader

Weekly Downloads

Combined read/write C2PA Flutter plugin with manifest signing and certificate management. Uses the official c2pa-rs Rust library via FFI.

Repository (GitHub)
View/report issues

Documentation

API reference

License

MIT (license)

Dependencies

equatable, flutter, flutter_rust_bridge, http, plugin_platform_interface

More

Packages that depend on c2pa_flutter

Packages that implement c2pa_flutter