kayiv_signature_pad 1.0.1
kayiv_signature_pad: ^1.0.1 copied to clipboard
A high-precision signature pad widget for Flutter, offering smooth rendering, pressure sensitivity, undo/redo, and advanced customization with export capabilities.
example/lib/main.dart
import 'package:flutter/material.dart';
import 'package:kayiv_signature_pad/kayiv_signature_pad.dart';
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:device_info_plus/device_info_plus.dart';
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
const MyApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: SignaturePadExample(),
);
}
}
class SignaturePadExample extends StatefulWidget {
const SignaturePadExample({Key? key}) : super(key: key);
@override
_SignaturePadExampleState createState() => _SignaturePadExampleState();
}
class _SignaturePadExampleState extends State<SignaturePadExample> {
final _controller = SignatureController(
penColor: Colors.blue,
minStrokeWidth: 1.0,
maxStrokeWidth: 5.0,
smoothRatio: 0.65,
penType: PenType.brush,
onDrawStart: () => print('Drawing started'),
onDrawEnd: () => print('Drawing ended'),
);
final _formKey = GlobalKey<FormState>();
late VoidCallback _listener;
// Pen control variables
double _currentMinWidth = 1.0;
double _currentMaxWidth = 5.0;
Color _currentColor = Colors.blue;
bool _enableTouchSensitivity = true;
bool _enablePressure = true;
// Enhanced export functions with better permission handling
Future<bool> _requestPermissions() async {
if (Platform.isAndroid) {
final isAndroid11Plus = await _isAndroid11OrHigher();
print('Android version check: ${isAndroid11Plus ? "11+" : "10 or below"}');
if (isAndroid11Plus) {
// For Android 11+ (API 30+), we need manage external storage
var manageStorageStatus = await Permission.manageExternalStorage.status;
print('Manage external storage status: $manageStorageStatus');
if (!manageStorageStatus.isGranted) {
// Show dialog explaining why we need permission
if (mounted && context.mounted) {
final shouldRequest = await _showPermissionDialog(
'Storage Permission Required',
'This app needs "Manage all files" permission to save signature files to your device. This permission allows the app to access and save files to your device storage.',
);
if (!shouldRequest) return false;
}
print('Requesting manage external storage permission...');
manageStorageStatus = await Permission.manageExternalStorage.request();
print('Manage external storage status after request: $manageStorageStatus');
if (!manageStorageStatus.isGranted) {
if (mounted && context.mounted) {
_showPermissionSettingsDialog();
}
return false;
}
}
return true;
} else {
// For Android 10 and below, use regular storage permission
var storageStatus = await Permission.storage.status;
print('Storage permission status: $storageStatus');
if (!storageStatus.isGranted) {
print('Requesting storage permission...');
storageStatus = await Permission.storage.request();
print('Storage permission status after request: $storageStatus');
if (!storageStatus.isGranted) {
if (mounted && context.mounted) {
_showPermissionSettingsDialog();
}
return false;
}
}
return true;
}
} else {
// For iOS, we don't need special permissions for app documents directory
print('iOS detected - no special permissions needed');
return true;
}
}
Future<bool> _isAndroid11OrHigher() async {
if (Platform.isAndroid) {
try {
final androidInfo = await DeviceInfoPlugin().androidInfo;
return androidInfo.version.sdkInt >= 30;
} catch (e) {
// Fallback: assume Android 11+ if we can't determine version
return true;
}
}
return false;
}
Future<bool> _showPermissionDialog(String title, String message) async {
return await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Grant Permission'),
),
],
);
},
) ?? false;
}
void _showPermissionSettingsDialog() async {
final isAndroid11Plus = await _isAndroid11OrHigher();
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Permission Required'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
isAndroid11Plus
? 'This app needs "Manage all files" permission to save signature files.'
: 'This app needs storage permission to save signature files.',
),
const SizedBox(height: 12),
Text(
isAndroid11Plus
? 'In Settings, look for:\n• "Permissions" or "App permissions"\n• "Files and media" or "Storage"\n• "Manage all files" or "All files access"'
: 'In Settings, look for:\n• "Permissions" or "App permissions"\n• "Storage" or "Files and media"\n• Enable the permission',
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
openAppSettings();
},
child: const Text('Open Settings'),
),
],
);
},
);
}
Future<bool> _showFallbackDialog() async {
return await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Save to App Directory'),
content: const Text(
'Storage permission was not granted. Would you like to save the file to the app\'s internal directory instead? This file will only be accessible within the app.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Save to App'),
),
],
);
},
) ?? false;
}
void _showFileLocationDialog(String filePath, String fileName, String fileType) {
final isAppDirectory = filePath.contains('app_flutter') || filePath.contains('Documents');
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text('$fileType File Location'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('File: $fileName'),
const SizedBox(height: 8),
Text(
isAppDirectory
? 'Location: App Internal Directory'
: 'Location: App External Directory',
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Text(
'Full Path:',
style: const TextStyle(fontWeight: FontWeight.bold),
),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.grey[200],
borderRadius: BorderRadius.circular(4),
),
child: SelectableText(
filePath,
style: const TextStyle(fontSize: 12, fontFamily: 'monospace'),
),
),
const SizedBox(height: 12),
Text(
isAppDirectory
? 'This file is saved in the app\'s internal directory. You can access it through the Files app or by connecting your device to a computer.'
: 'This file is saved in the app\'s external directory. On Android, this is typically accessible through the device\'s file manager in the app\'s folder.',
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('OK'),
),
],
);
},
);
}
Future<void> _showSavedFiles() async {
try {
final List<String> directories = [];
// Get external storage directory
try {
final externalDir = await getExternalStorageDirectory();
if (externalDir != null) {
final picturesDir = Directory('${externalDir.path}/Pictures');
if (await picturesDir.exists()) {
directories.add(picturesDir.path);
}
}
} catch (e) {
// External storage not available
}
// Get app documents directory
try {
final appDir = await getApplicationDocumentsDirectory();
directories.add(appDir.path);
} catch (e) {
// App directory not available
}
if (directories.isEmpty) {
if (mounted && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No accessible directories found'),
backgroundColor: Colors.red,
),
);
}
return;
}
final List<FileSystemEntity> allFiles = [];
for (final dirPath in directories) {
try {
final dir = Directory(dirPath);
final files = await dir.list().where((entity) =>
entity is File &&
(entity.path.endsWith('.png') || entity.path.endsWith('.svg'))
).toList();
allFiles.addAll(files);
} catch (e) {
// Directory not accessible
}
}
if (mounted && context.mounted) {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Saved Signature Files'),
content: SizedBox(
width: double.maxFinite,
height: 300,
child: allFiles.isEmpty
? const Center(
child: Text('No signature files found'),
)
: ListView.builder(
itemCount: allFiles.length,
itemBuilder: (context, index) {
final file = allFiles[index] as File;
final fileName = file.path.split('/').last;
final isAppDirectory = file.path.contains('app_flutter') || file.path.contains('Documents');
return ListTile(
title: Text(fileName),
subtitle: Text(
isAppDirectory
? 'App Internal Directory'
: 'App External Directory',
),
trailing: IconButton(
icon: const Icon(Icons.info),
onPressed: () {
Navigator.of(context).pop();
_showFileLocationDialog(file.path, fileName, fileName.endsWith('.png') ? 'PNG' : 'SVG');
},
),
);
},
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
),
],
);
},
);
}
} catch (e) {
if (mounted && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error loading files: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
Future<String?> _getSaveDirectory({bool forceAppDirectory = false}) async {
if (forceAppDirectory) {
// Force save to app documents directory (internal)
try {
final directory = await getApplicationDocumentsDirectory();
return directory.path;
} catch (e) {
return null;
}
}
try {
if (Platform.isAndroid) {
// For Android, try to save to app's external directory first
final externalDir = await getExternalStorageDirectory();
if (externalDir != null) {
// Create a Pictures directory in the app's external directory
final picturesDir = Directory('${externalDir.path}/Pictures');
if (!await picturesDir.exists()) {
await picturesDir.create(recursive: true);
}
return picturesDir.path;
}
} else {
// For iOS, use the app documents directory
final directory = await getApplicationDocumentsDirectory();
return directory.path;
}
} catch (e) {
// Fallback to app documents directory
}
// Fallback to app documents directory (internal)
try {
final directory = await getApplicationDocumentsDirectory();
return directory.path;
} catch (e) {
return null;
}
}
Future<void> _exportAsPng() async {
if (_controller.isEmpty) {
if (mounted && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please draw a signature first'),
backgroundColor: Colors.orange,
),
);
}
return;
}
try {
final hasPermission = await _requestPermissions();
bool forceAppDirectory = false;
if (!hasPermission) {
// Try to save to app directory as fallback
if (mounted && context.mounted) {
final shouldSaveToApp = await _showFallbackDialog();
if (!shouldSaveToApp) return;
forceAppDirectory = true;
}
}
final bytes = await _controller.toPngBytes();
if (bytes == null) {
if (mounted && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Failed to generate PNG image'),
backgroundColor: Colors.red,
),
);
}
return;
}
final saveDir = await _getSaveDirectory(forceAppDirectory: forceAppDirectory);
if (saveDir == null) {
if (mounted && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Failed to access save directory'),
backgroundColor: Colors.red,
),
);
}
return;
}
final timestamp = DateTime.now().millisecondsSinceEpoch;
final fileName = 'signature_$timestamp.png';
final file = File('$saveDir/$fileName');
await file.writeAsBytes(bytes);
if (mounted && context.mounted) {
final fileName = file.path.split('/').last;
final isAppDirectory = file.path.contains('app_flutter') || file.path.contains('Documents');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('PNG saved as: $fileName'),
Text(
isAppDirectory
? 'Saved to app directory (internal storage)'
: 'Saved to app external directory',
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
duration: const Duration(seconds: 6),
action: SnackBarAction(
label: 'View Path',
onPressed: () {
_showFileLocationDialog(file.path, fileName, 'PNG');
},
),
),
);
}
} catch (e) {
if (mounted && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error saving PNG: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
Future<void> _exportAsSvg() async {
if (_controller.isEmpty) {
if (mounted && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please draw a signature first'),
backgroundColor: Colors.orange,
),
);
}
return;
}
try {
final hasPermission = await _requestPermissions();
bool forceAppDirectory = false;
if (!hasPermission) {
// Try to save to app directory as fallback
if (mounted && context.mounted) {
final shouldSaveToApp = await _showFallbackDialog();
if (!shouldSaveToApp) return;
forceAppDirectory = true;
}
}
final svgData = await _controller.toSvg();
if (svgData.isEmpty) {
if (mounted && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Failed to generate SVG'),
backgroundColor: Colors.red,
),
);
}
return;
}
final saveDir = await _getSaveDirectory(forceAppDirectory: forceAppDirectory);
if (saveDir == null) {
if (mounted && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Failed to access save directory'),
backgroundColor: Colors.red,
),
);
}
return;
}
final timestamp = DateTime.now().millisecondsSinceEpoch;
final fileName = 'signature_$timestamp.svg';
final file = File('$saveDir/$fileName');
await file.writeAsString(svgData);
if (mounted && context.mounted) {
final fileName = file.path.split('/').last;
final isAppDirectory = file.path.contains('app_flutter') || file.path.contains('Documents');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('SVG saved as: $fileName'),
Text(
isAppDirectory
? 'Saved to app directory (internal storage)'
: 'Saved to app external directory',
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
duration: const Duration(seconds: 6),
action: SnackBarAction(
label: 'View Path',
onPressed: () {
_showFileLocationDialog(file.path, fileName, 'SVG');
},
),
),
);
}
} catch (e) {
if (mounted && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error saving SVG: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
void _showExportDialog() {
if (!mounted || !context.mounted) return;
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text('Export Signature'),
content: const Text('Choose export format:'),
actions: [
TextButton(
onPressed: () {
Navigator.of(dialogContext).pop();
_exportAsPng();
},
child: const Text('PNG Image'),
),
TextButton(
onPressed: () {
Navigator.of(dialogContext).pop();
_exportAsSvg();
},
child: const Text('SVG Vector'),
),
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
],
);
},
);
}
@override
void initState() {
super.initState();
_listener = () => setState(() {});
_controller.addListener(_listener);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Signature Pad')),
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
children: [
SignatureFormField(
controller: _controller,
backgroundColor: Colors.grey[200]!,
width: double.infinity,
height: 300,
validator: (value) => _controller.isEmpty ? 'Signature required' : null,
),
const SizedBox(height: 16),
// Pen Controls
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Pen Controls', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
// Color Picker
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Color: '),
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
...Colors.primaries.take(8).map((color) =>
GestureDetector(
onTap: () {
setState(() {
_currentColor = color;
_controller.setPenColor(color);
});
},
child: Container(
width: 30,
height: 30,
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: color,
border: Border.all(
color: _currentColor == color ? Colors.black : Colors.grey,
width: _currentColor == color ? 3 : 1,
),
borderRadius: BorderRadius.circular(15),
),
),
),
),
],
),
),
],
),
const SizedBox(height: 12),
// Pen Size Controls
Row(
children: [
const Text('Pen Size: '),
const SizedBox(width: 8),
IconButton(
onPressed: () {
setState(() {
_currentMinWidth = (_currentMinWidth - 0.5).clamp(0.5, _currentMaxWidth - 1);
_currentMaxWidth = (_currentMaxWidth - 0.5).clamp(_currentMinWidth + 1, 10.0);
_controller.setPenSize(_currentMinWidth, _currentMaxWidth);
});
},
icon: const Icon(Icons.remove),
),
Text('${_currentMinWidth.toStringAsFixed(1)} - ${_currentMaxWidth.toStringAsFixed(1)}'),
IconButton(
onPressed: () {
setState(() {
_currentMinWidth = (_currentMinWidth + 0.5).clamp(0.5, _currentMaxWidth - 1);
_currentMaxWidth = (_currentMaxWidth + 0.5).clamp(_currentMinWidth + 1, 10.0);
_controller.setPenSize(_currentMinWidth, _currentMaxWidth);
});
},
icon: const Icon(Icons.add),
),
],
),
const SizedBox(height: 12),
// Sensitivity Controls
Row(
children: [
const Text('Touch Sensitivity: '),
const SizedBox(width: 8),
Switch(
value: _enableTouchSensitivity,
onChanged: (value) {
setState(() {
_enableTouchSensitivity = value;
_controller.setTouchSensitivity(value);
});
},
),
Text(_enableTouchSensitivity ? 'On' : 'Off'),
],
),
const SizedBox(height: 8),
// Pressure Controls
Row(
children: [
const Text('Pressure Sensitivity: '),
const SizedBox(width: 8),
Switch(
value: _enablePressure,
onChanged: (value) {
setState(() {
_enablePressure = value;
_controller.setPressure(value);
});
},
),
Text(_enablePressure ? 'On' : 'Off'),
],
),
],
),
),
),
const SizedBox(height: 16),
// Action Buttons
Wrap(
spacing: 8,
children: [
ElevatedButton(
onPressed: _controller.undo,
child: const Text('Undo'),
),
ElevatedButton(
onPressed: _controller.redo,
child: const Text('Redo'),
),
ElevatedButton(
onPressed: _controller.clear,
child: const Text('Clear'),
),
ElevatedButton(
onPressed: _showExportDialog,
child: const Text('Export'),
),
ElevatedButton(
onPressed: _showSavedFiles,
child: const Text('View Files'),
),
ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Form validated')),
);
}
},
child: const Text('Submit'),
),
],
),
],
),
),
),
),
);
}
@override
void dispose() {
_controller.removeListener(_listener);
_controller.dispose();
super.dispose();
}
}