worksheet 1.6.0
worksheet: ^1.6.0 copied to clipboard
High-performance Flutter spreadsheet widget supporting large datasets, 10%-400% zoom, and GPU-optimized tile-based rendering.
example/main.dart
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:worksheet/worksheet.dart';
/// Converts a slider value (0.0 to 1.0) to a zoom level (0.1 to 4.0).
///
/// Uses a non-linear scale where:
/// - 0.0 → 10% zoom (0.1)
/// - 0.5 → 100% zoom (1.0)
/// - 1.0 → 400% zoom (4.0)
double sliderToZoom(double sliderValue) {
if (sliderValue <= 0.5) {
// Left half: exponential from 0.1 to 1.0
// zoom = 0.1 * 10^(sliderValue * 2)
return 0.1 * math.pow(10, sliderValue * 2);
} else {
// Right half: exponential from 1.0 to 4.0
// zoom = 4^(2 * sliderValue - 1)
return math.pow(4, 2 * sliderValue - 1).toDouble();
}
}
/// Converts a zoom level (0.1 to 4.0) to a slider value (0.0 to 1.0).
///
/// Inverse of [sliderToZoom].
double zoomToSlider(double zoom) {
if (zoom <= 1.0) {
// Left half: zoom = 0.1 * 10^(slider * 2)
// slider = log10(zoom / 0.1) / 2 = log10(zoom * 10) / 2
return (math.log(zoom * 10) / math.ln10) / 2;
} else {
// Right half: zoom = 4^(2 * slider - 1)
// slider = (log4(zoom) + 1) / 2
return ((math.log(zoom) / math.log(4)) + 1) / 2;
}
}
void main() {
runApp(const WorksheetExampleApp());
}
class WorksheetExampleApp extends StatelessWidget {
const WorksheetExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Worksheet Example',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const WorksheetExample(),
);
}
}
class WorksheetExample extends StatefulWidget {
const WorksheetExample({super.key});
@override
State<WorksheetExample> createState() => _WorksheetExampleState();
}
class _WorksheetExampleState extends State<WorksheetExample> {
// Modern Excel dimensions (Excel 2007+)
static const int _rowCount = 1048576; // 2^20 rows
static const int _columnCount = 16384; // 2^14 columns (A to XFD)
// Excel default sizes (approximately)
static const double _defaultRowHeight = 20.0; // Excel default ~15 points = ~20 pixels
static const double _defaultColumnWidth = 64.0; // Excel default 8.43 characters ≈ 64 pixels
late final SparseWorksheetData _data;
late final WorksheetController _controller;
late final EditController _editController;
@override
void initState() {
super.initState();
_data = SparseWorksheetData(rowCount: _rowCount, columnCount: _columnCount);
_populateSampleData();
_controller = WorksheetController();
_editController = EditController();
}
void _populateSampleData() {
final random = math.Random(42); // Fixed seed for reproducibility
// === Sheet 1: Sales Data (realistic business spreadsheet) ===
// Header row with formatting
final headers = [
'ID',
'Date',
'Customer',
'Region',
'Product',
'Category',
'Quantity',
'Unit Price',
'Total',
'Discount',
'Net Total',
'Status',
'Sales Rep',
'Notes',
];
// Header style
const headerStyle = CellStyle(
backgroundColor: Color(0xFF4472C4),
textColor: Color(0xFFFFFFFF),
fontWeight: FontWeight.bold,
textAlignment: CellTextAlignment.center,
);
for (var col = 0; col < headers.length; col++) {
_data.setCell(CellCoordinate(0, col), CellValue.text(headers[col]));
_data.setStyle(const CellCoordinate(0, 0).copyWith(column: col), headerStyle);
}
// Sample data arrays
final customers = [
'Acme Corp', 'TechStart Inc', 'Global Industries', 'Smith & Co',
'Johnson LLC', 'Pacific Trading', 'Atlantic Imports', 'Central Services',
'Northern Supplies', 'Southern Distribution', 'Eastern Partners', 'Western Logistics',
'Metro Solutions', 'Urban Enterprises', 'Rural Products', 'Coastal Goods',
];
final regions = ['North', 'South', 'East', 'West', 'Central'];
final products = [
'Widget A', 'Widget B', 'Gadget X', 'Gadget Y', 'Tool Pro',
'Tool Basic', 'Device Alpha', 'Device Beta', 'Component 1', 'Component 2',
'Assembly Kit', 'Repair Kit', 'Starter Pack', 'Premium Pack', 'Enterprise Suite',
];
final categories = ['Electronics', 'Hardware', 'Software', 'Services', 'Accessories'];
final statuses = ['Completed', 'Pending', 'Shipped', 'Processing', 'Cancelled'];
final salesReps = [
'Alice Johnson', 'Bob Smith', 'Carol White', 'David Brown',
'Emma Davis', 'Frank Wilson', 'Grace Lee', 'Henry Taylor',
];
// Number styles
const currencyStyle = CellStyle(
textAlignment: CellTextAlignment.right,
);
const numberStyle = CellStyle(
textAlignment: CellTextAlignment.right,
);
// Alternating row colors
const evenRowStyle = CellStyle(
backgroundColor: Color(0xFFF2F2F2),
);
// Generate 50,000 rows of sales data (simulating a large dataset)
final baseDate = DateTime(2024, 1, 1);
for (var row = 1; row <= 50000; row++) {
final date = baseDate.add(Duration(days: random.nextInt(365)));
final customer = customers[random.nextInt(customers.length)];
final region = regions[random.nextInt(regions.length)];
final product = products[random.nextInt(products.length)];
final category = categories[random.nextInt(categories.length)];
final quantity = random.nextInt(100) + 1;
final unitPrice = (random.nextDouble() * 500 + 10).roundToDouble();
final total = quantity * unitPrice;
final discountPercent = random.nextInt(20);
final discount = total * discountPercent / 100;
final netTotal = total - discount;
final status = statuses[random.nextInt(statuses.length)];
final salesRep = salesReps[random.nextInt(salesReps.length)];
// Set cell values
_data.setCell(CellCoordinate(row, 0), CellValue.number(row.toDouble()));
_data.setCell(CellCoordinate(row, 1), CellValue.text('${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'));
_data.setCell(CellCoordinate(row, 2), CellValue.text(customer));
_data.setCell(CellCoordinate(row, 3), CellValue.text(region));
_data.setCell(CellCoordinate(row, 4), CellValue.text(product));
_data.setCell(CellCoordinate(row, 5), CellValue.text(category));
_data.setCell(CellCoordinate(row, 6), CellValue.number(quantity.toDouble()));
_data.setCell(CellCoordinate(row, 7), CellValue.number(unitPrice));
_data.setCell(CellCoordinate(row, 8), CellValue.number(total));
_data.setCell(CellCoordinate(row, 9), CellValue.text('$discountPercent%'));
_data.setCell(CellCoordinate(row, 10), CellValue.number(netTotal));
_data.setCell(CellCoordinate(row, 11), CellValue.text(status));
_data.setCell(CellCoordinate(row, 12), CellValue.text(salesRep));
// Add occasional notes
if (random.nextInt(10) == 0) {
_data.setCell(CellCoordinate(row, 13), CellValue.text('Follow up required'));
}
// Apply alternating row style
if (row.isEven) {
for (var col = 0; col < headers.length; col++) {
_data.setStyle(CellCoordinate(row, col), evenRowStyle);
}
}
// Apply number alignment
_data.setStyle(CellCoordinate(row, 0), numberStyle);
_data.setStyle(CellCoordinate(row, 6), numberStyle);
_data.setStyle(CellCoordinate(row, 7), currencyStyle);
_data.setStyle(CellCoordinate(row, 8), currencyStyle);
_data.setStyle(CellCoordinate(row, 10), currencyStyle);
// Highlight cancelled orders in red
if (status == 'Cancelled') {
_data.setStyle(CellCoordinate(row, 11), const CellStyle(
textColor: Color(0xFFCC0000),
fontWeight: FontWeight.bold,
));
}
}
// === Add summary section ===
const summaryStartRow = 50002;
_data.setCell(const CellCoordinate(summaryStartRow, 0), CellValue.text('SUMMARY'));
_data.setStyle(const CellCoordinate(summaryStartRow, 0), const CellStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
));
_data.setCell(const CellCoordinate(summaryStartRow + 1, 0), CellValue.text('Total Records:'));
_data.setCell(const CellCoordinate(summaryStartRow + 1, 1), CellValue.number(50000));
_data.setCell(const CellCoordinate(summaryStartRow + 2, 0), CellValue.text('Report Generated:'));
_data.setCell(const CellCoordinate(summaryStartRow + 2, 1), CellValue.text(DateTime.now().toString().substring(0, 19)));
_data.setCell(const CellCoordinate(summaryStartRow + 3, 0), CellValue.text('Grid Size:'));
_data.setCell(const CellCoordinate(summaryStartRow + 3, 1), CellValue.text('1,048,576 rows × 16,384 columns (XFD)'));
// === Additional data in columns O onwards (simulating more sheets/data) ===
// Add a separate "lookup table" starting at column Q (index 16)
_data.setCell(const CellCoordinate(0, 16), CellValue.text('PRODUCT CATALOG'));
_data.setStyle(const CellCoordinate(0, 16), const CellStyle(
fontWeight: FontWeight.bold,
backgroundColor: Color(0xFF70AD47),
textColor: Color(0xFFFFFFFF),
));
final catalogHeaders = ['Code', 'Name', 'Base Price', 'In Stock'];
for (var col = 0; col < catalogHeaders.length; col++) {
_data.setCell(CellCoordinate(1, 16 + col), CellValue.text(catalogHeaders[col]));
_data.setStyle(CellCoordinate(1, 16 + col), const CellStyle(
backgroundColor: Color(0xFFE2EFDA),
fontWeight: FontWeight.bold,
));
}
for (var i = 0; i < products.length; i++) {
_data.setCell(CellCoordinate(2 + i, 16), CellValue.text('PRD-${(i + 1).toString().padLeft(3, '0')}'));
_data.setCell(CellCoordinate(2 + i, 17), CellValue.text(products[i]));
_data.setCell(CellCoordinate(2 + i, 18), CellValue.number((random.nextDouble() * 400 + 50).roundToDouble()));
_data.setCell(CellCoordinate(2 + i, 19), CellValue.number((random.nextInt(1000) + 50).toDouble()));
}
// === Data at far corners to test large grid navigation ===
// Data at row 100,000
_data.setCell(const CellCoordinate(100000, 0), CellValue.text('DATA AT ROW 100,001'));
_data.setStyle(const CellCoordinate(100000, 0), const CellStyle(
fontWeight: FontWeight.bold,
backgroundColor: Color(0xFFFFEB9C),
));
for (var col = 1; col < 10; col++) {
_data.setCell(CellCoordinate(100000, col), CellValue.number((random.nextDouble() * 1000).roundToDouble()));
}
// Data at row 500,000
_data.setCell(const CellCoordinate(500000, 0), CellValue.text('DATA AT ROW 500,001'));
_data.setStyle(const CellCoordinate(500000, 0), const CellStyle(
fontWeight: FontWeight.bold,
backgroundColor: Color(0xFFFFEB9C),
));
for (var col = 1; col < 10; col++) {
_data.setCell(CellCoordinate(500000, col), CellValue.number((random.nextDouble() * 1000).roundToDouble()));
}
// Data at the last row (1,048,575)
_data.setCell(const CellCoordinate(1048575, 0), CellValue.text('LAST ROW (1,048,576)'));
_data.setStyle(const CellCoordinate(1048575, 0), const CellStyle(
fontWeight: FontWeight.bold,
backgroundColor: Color(0xFFFF6B6B),
textColor: Color(0xFFFFFFFF),
));
// Data at column 1000 (ALM)
_data.setCell(const CellCoordinate(0, 1000), CellValue.text('COLUMN 1001 (ALM)'));
_data.setStyle(const CellCoordinate(0, 1000), const CellStyle(
fontWeight: FontWeight.bold,
backgroundColor: Color(0xFF9B59B6),
textColor: Color(0xFFFFFFFF),
));
for (var row = 1; row <= 100; row++) {
_data.setCell(CellCoordinate(row, 1000), CellValue.number((random.nextDouble() * 500).roundToDouble()));
}
// Data at column 10000 (NTQ)
_data.setCell(const CellCoordinate(0, 10000), CellValue.text('COLUMN 10001 (NTQ)'));
_data.setStyle(const CellCoordinate(0, 10000), const CellStyle(
fontWeight: FontWeight.bold,
backgroundColor: Color(0xFF3498DB),
textColor: Color(0xFFFFFFFF),
));
for (var row = 1; row <= 50; row++) {
_data.setCell(CellCoordinate(row, 10000), CellValue.number((random.nextDouble() * 500).roundToDouble()));
}
// Data at last column (16383 = XFD)
_data.setCell(const CellCoordinate(0, 16383), CellValue.text('LAST COL (XFD)'));
_data.setStyle(const CellCoordinate(0, 16383), const CellStyle(
fontWeight: FontWeight.bold,
backgroundColor: Color(0xFFFF6B6B),
textColor: Color(0xFFFFFFFF),
));
// Corner cell - last row, last column
_data.setCell(const CellCoordinate(1048575, 16383), CellValue.text('XFD1048576'));
_data.setStyle(const CellCoordinate(1048575, 16383), const CellStyle(
fontWeight: FontWeight.bold,
backgroundColor: Color(0xFF2ECC71),
textColor: Color(0xFFFFFFFF),
));
}
@override
void dispose() {
_controller.dispose();
_editController.dispose();
_data.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Worksheet Demo - Sales Data'),
actions: [
// Zoom slider with non-linear scale
ListenableBuilder(
listenable: _controller,
builder: (context, _) => Row(
mainAxisSize: MainAxisSize.min,
children: [
const Text('10%', style: TextStyle(fontSize: 11)),
SizedBox(
width: 180,
child: Slider(
value: zoomToSlider(_controller.zoom),
onChanged: (value) {
final zoom = sliderToZoom(value);
_controller.setZoom(zoom);
setState(() {});
},
divisions: 100,
),
),
const Text('400%', style: TextStyle(fontSize: 11)),
const SizedBox(width: 8),
SizedBox(
width: 45,
child: Text(
'${(_controller.zoom * 100).round()}%',
style: const TextStyle(fontWeight: FontWeight.bold),
),
),
],
),
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
_controller.resetZoom();
setState(() {});
},
tooltip: 'Reset to 100%',
),
],
),
body: Column(
children: [
_buildSelectionInfo(),
_buildInstructions(),
Expanded(
child: WorksheetTheme(
data: WorksheetThemeData(
showHeaders: true,
showGridlines: true,
defaultRowHeight: _defaultRowHeight,
defaultColumnWidth: _defaultColumnWidth,
rowHeaderWidth: 40.0, // Narrower like Excel
columnHeaderHeight: 20.0, // Shorter like Excel
fontSize: 11.0, // Smaller font like Excel
),
child: Worksheet(
data: _data,
controller: _controller,
editController: _editController,
rowCount: _rowCount,
columnCount: _columnCount,
),
),
),
],
),
);
}
Widget _buildSelectionInfo() {
return ListenableBuilder(
listenable: _controller,
builder: (context, _) {
final selection = _controller.selectedRange;
final focus = _controller.focusCell;
final cellValue = focus != null ? _data.getCell(focus) : null;
String text;
if (selection != null) {
final start = CellCoordinate(selection.startRow, selection.startColumn);
final end = CellCoordinate(selection.endRow, selection.endColumn);
if (start == end) {
text = 'Selected: ${start.toNotation()}';
} else {
text = 'Selected: ${start.toNotation()}:${end.toNotation()}';
}
if (cellValue != null) {
text += ' = ${cellValue.displayValue}';
}
} else {
text = 'No selection - click a cell to select';
}
return Container(
padding: const EdgeInsets.all(8.0),
color: Colors.grey[200],
width: double.infinity,
child: Text(text, style: const TextStyle(fontSize: 12)),
);
},
);
}
Widget _buildInstructions() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
color: Colors.blue[50],
width: double.infinity,
child: const Text(
'Type to edit | Enter/Tab to commit & navigate | Escape to cancel | Double-click or F2 to edit | Drag headers to resize',
style: TextStyle(fontSize: 11, color: Colors.blueGrey),
),
);
}
}