tostore 3.5.0
tostore: ^3.5.0 copied to clipboard
Fast distributed AI vector database and persistent local storage engine. High-performance key-value store supporting SQL, NoSQL, offline cache and encrypted data.
import 'dart:async';
import 'dart:convert';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:tostore/tostore.dart';
import 'testing/benchmark_dialog.dart';
import 'testing/benchmark_models.dart';
import 'testing/benchmark_runner.dart';
import 'testing/database_tester.dart';
import 'testing/example_schemas.dart';
import 'testing/log_service.dart';
import 'tostore_example.dart' show ForeignKeyMode, ToStoreExample;
String _dbResultErrorMessage(DbResult result) {
final errors = result.statuses
.where((s) => s.type != ResultType.success)
.map((s) => s.message);
return errors.isEmpty ? 'Operation failed' : errors.join('; ');
}
/// Simple UI to run examples
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// It's crucial to set the log handler *before* any potential errors can occur.
// This ensures that initialization logs are captured and displayed in the UI.
ToStore.setLogConfig(
logLevel: LogLevel.debug,
onLog: (LogRecord log) {
final message = log.message;
final type = log.level;
// Filter out expected test-induced calculation fallback logs
if (message.contains('Division by zero in expression')) {
return;
}
logService.add(message, type, true);
},
);
final example = ToStoreExample();
// The example app will now run even if initialization fails,
// allowing the user to see the error logs in the ListView.
runApp(MyApp(example: example));
}
class MyApp extends StatelessWidget {
const MyApp({super.key, required this.example});
final ToStoreExample example;
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'ToStore Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF006CC3),
primary: const Color(0xFF006CC3),
surface: Colors.white,
surfaceTint: Colors.transparent,
),
useMaterial3: true,
scaffoldBackgroundColor: Colors.white,
popupMenuTheme: PopupMenuThemeData(
color: Colors.white,
surfaceTintColor: Colors.transparent,
elevation: 6,
shadowColor: Colors.black.withAlpha(25),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Color(0xFFE2E8F0), width: 1),
),
),
dialogTheme: DialogThemeData(
backgroundColor: Colors.white,
surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
),
visualDensity: VisualDensity.adaptivePlatformDensity,
),
home: ToStoreExamplePage(example: example),
);
}
}
enum AppView { dataView, tests }
enum PaginationMode { offset, cursor }
class ToStoreExamplePage extends StatefulWidget {
const ToStoreExamplePage({super.key, required this.example});
final ToStoreExample example;
@override
State<ToStoreExamplePage> createState() => _ToStoreExamplePageState();
}
class _ToStoreExamplePageState extends State<ToStoreExamplePage> {
static const bool _isWasmBuild =
bool.fromEnvironment('FLUTTER_WEB_USE_SKWASM');
/// Shared content column width for app bar, body, and log panel content.
static const double _kContentMaxWidth = 1200.0;
final TextEditingController _searchController = TextEditingController();
late final PageController _pageViewController;
LogLevel? _selectedLogLevel;
String _lastOperationInfo = 'Please initialize the database first.';
bool _isDbInitialized = false;
bool _isInitializing = true;
bool _isTesting = false; // Add state to track if a test is running
bool _isAtBottom = true; // Assume we start at the bottom
BenchmarkSummary? _lastBenchmarkSummary;
BenchmarkConfig _benchmarkConfig = const BenchmarkConfig();
AppView _selectedView = AppView.dataView;
// State for spaces
final List<String> _spaceNames = ['default', 'space1', 'space2'];
String _selectedSpace = 'default';
// State for Data View
final List<String> _tableNames = [
ExampleSchemas.users.name,
ExampleSchemas.posts.name,
ExampleSchemas.comments.name,
ExampleSchemas.embeddings.name,
ExampleSchemas.settings.name,
_kKvSpaceLabel,
_kKvGlobalLabel,
];
String _selectedTable = ExampleSchemas.users.name;
bool _hasVectorSupport = false;
bool get _isKvMode =>
_selectedTable == _kKvSpaceLabel || _selectedTable == _kKvGlobalLabel;
bool get _isKvGlobal => _selectedTable == _kKvGlobalLabel;
List<Map<String, dynamic>> _tableData = [];
List<String> _tableColumns = [];
int _currentPage = 1;
final int _pageSize = 20;
int _totalRecords = 0;
int _totalPages = 0;
bool _isDataLoading = false;
bool _isCountCalculating = false;
int _fetchSequence = 0;
bool _isCountLimited =
false; // Indicates if count might be limited by defaultQueryLimit
String? _primaryKey;
final Set<dynamic> _selectedRows = {};
// Pagination state
PaginationMode _paginationMode = PaginationMode.cursor;
String? _nextCursor;
String? _prevCursor;
final TextEditingController _pageInputController = TextEditingController();
final DraggableScrollableController _logPanelController =
DraggableScrollableController();
// The scroll controller provided by the DraggableScrollableSheet builder.
// We need to hold a reference to it to manage listeners correctly.
ScrollController? _sheetScrollController;
bool _logCanScrollUp = false;
bool _logCanScrollDown = false;
// New state for sorting
String? _sortColumn;
bool _sortAscending = true;
// New state for active filters
List<Map<String, dynamic>> _activeFilters = [];
/// Collapsed log handle height — watch strip matches this so the main list
/// stays usable while a single-row live watch is open.
static const double _kBottomChromeHeight = 60;
/// Live single-record watch (query().watch on PK). Survives switchSpace.
String? _watchTable;
String? _watchPkField;
dynamic _watchPkValue;
Map<String, dynamic>? _watchRow;
bool _watchMissing = false;
int _watchPulse = 0;
StreamSubscription<List<Map<String, dynamic>>>? _watchSub;
bool get _hasRecordWatch => _watchTable != null && _watchPkValue != null;
@override
void initState() {
super.initState();
_pageViewController = PageController(initialPage: _selectedView.index);
_initializeDatabase();
logService.logs.addListener(_onLogsChanged);
_searchController.addListener(() {
setState(() {
// Just rebuild the widget when text changes
});
});
}
@override
void dispose() {
unawaited(_stopRecordWatch());
logService.logs.removeListener(_onLogsChanged);
_logPanelController.dispose();
_sheetScrollController?.removeListener(_logScrollListener);
_searchController.dispose();
_pageViewController.dispose();
_pageInputController.dispose();
super.dispose();
}
Future<void> _fetchTableData({bool resetPage = false, String? cursor}) async {
if (!_isDbInitialized) return;
if (_isKvMode) {
await _fetchKvData(resetPage: resetPage, cursor: cursor);
return;
}
final int currentSeq = ++_fetchSequence;
setState(() {
_isDataLoading = true;
if (resetPage) {
_currentPage = 1;
_selectedRows.clear();
_sortColumn = null; // Reset sort on page reset
_nextCursor = null;
_prevCursor = null;
}
});
try {
// 1. Get schema to find columns and PK
final schema = await widget.example.db.getTableSchema(_selectedTable);
if (schema != null) {
_tableColumns = schema.fields.map((f) => f.name).toList();
_primaryKey = schema.primaryKeyConfig.name;
if (!_tableColumns.contains(_primaryKey)) {
_tableColumns.insert(0, _primaryKey!);
}
_hasVectorSupport =
schema.indexes.any((idx) => idx.type == IndexType.vector);
} else {
_tableColumns = [];
_primaryKey = 'key';
_hasVectorSupport = false;
}
// 2. Base query for data fetching
var dataQuery = widget.example.db.query(_selectedTable);
for (final filter in _activeFilters) {
final field = filter['field'] as String;
final op = filter['operator'] as String;
final value = filter['value'];
if (op == 'startsWith' || op == 'prefix') {
dataQuery = dataQuery.whereStartsWith(field, value?.toString() ?? '');
} else {
dataQuery = dataQuery.where(field, op, value);
}
}
// 3. Base query for count calculation (matching filters)
var countQuery = widget.example.db.query(_selectedTable);
for (final filter in _activeFilters) {
final field = filter['field'] as String;
final op = filter['operator'] as String;
final value = filter['value'];
if (op == 'startsWith' || op == 'prefix') {
countQuery =
countQuery.whereStartsWith(field, value?.toString() ?? '');
} else {
countQuery = countQuery.where(field, op, value);
}
}
// 4. Apply sorting for data fetching
if (_sortColumn != null) {
if (_sortAscending) {
dataQuery = dataQuery.orderByAsc(_sortColumn!);
} else {
dataQuery = dataQuery.orderByDesc(_sortColumn!);
}
}
final int maxOffset = widget.example.db.config.maxQueryOffset;
late final QueryResult<Map<String, dynamic>> result;
if (_paginationMode == PaginationMode.offset) {
final int offset = (_currentPage - 1) * _pageSize;
if (!mounted) return;
if (offset > maxOffset) {
_showOffsetLimitWarning(offset, maxOffset);
setState(() => _isDataLoading = false);
return;
}
result = await dataQuery.limit(_pageSize).offset(offset);
} else {
var q = dataQuery.limit(_pageSize);
if (cursor != null) {
q = q.cursor(cursor);
}
result = await q;
}
if (!mounted || currentSeq != _fetchSequence) return;
setState(() {
_tableData = result.data;
_nextCursor = result.nextCursorToken;
_prevCursor = result.prevCursorToken;
if (_tableColumns.isEmpty && _tableData.isNotEmpty) {
_tableColumns = _tableData.first.keys.toList();
}
_isDataLoading = false;
_pageInputController.text = _currentPage.toString();
});
final filterHint = _activeFilters.isEmpty
? 'no filter'
: '${_activeFilters.length} filter(s)';
final modeHint = _paginationMode == PaginationMode.cursor
? 'cursor'
: 'offset page $_currentPage';
final elapsedMs = result.executionTimeMs;
logService.add(
'Query table "$_selectedTable" ($modeHint, $filterHint): '
'fetched ${_tableData.length} records '
'in ${elapsedMs ?? '?'}ms',
LogLevel.info);
// 5. Asynchronously update record count in background without blocking list rendering
_asyncCalculateTableCount(countQuery, currentSeq);
} catch (e, s) {
logService.add('Error fetching table data: $e', LogLevel.error);
logService.add('Stacktrace: $s', LogLevel.error);
if (mounted) {
setState(() => _isDataLoading = false);
}
}
}
Future<void> _asyncCalculateTableCount(
QueryBuilder countQuery, int sequence) async {
try {
if (mounted && sequence == _fetchSequence) {
setState(() => _isCountCalculating = true);
}
final count = await countQuery.count();
if (!mounted || sequence != _fetchSequence) return;
final defaultQueryLimit = widget.example.db.config.defaultQueryLimit;
setState(() {
_totalRecords = count;
_isCountLimited =
_activeFilters.isNotEmpty && _totalRecords == defaultQueryLimit;
_totalPages = (_totalRecords / _pageSize).ceil();
if (_totalPages == 0) _totalPages = 1;
if (_currentPage > _totalPages &&
_paginationMode == PaginationMode.offset) {
_currentPage = _totalPages;
_pageInputController.text = _currentPage.toString();
}
_isCountCalculating = false;
});
} catch (_) {
if (mounted && sequence == _fetchSequence) {
setState(() => _isCountCalculating = false);
}
}
}
/// Loads KV records via [KvStore.query] (limit/offset/cursor; not getKeys + N gets).
Future<void> _fetchKvData({bool resetPage = false, String? cursor}) async {
final previousNextCursor = _nextCursor;
final previousPrevCursor = _prevCursor;
final int currentSeq = ++_fetchSequence;
setState(() {
_isDataLoading = true;
if (resetPage) {
_currentPage = 1;
_selectedRows.clear();
_sortColumn = null;
_nextCursor = null;
_prevCursor = null;
}
});
try {
final isGlobal = _isKvGlobal;
final kv = widget.example.db.kv;
_tableColumns = const ['key', 'value', 'updated_at'];
_primaryKey = 'key';
_hasVectorSupport = false;
_isCountLimited = false;
String? prefix;
for (final filter in _activeFilters) {
if (filter['field'] == 'key' &&
(filter['operator'] == 'startsWith' ||
filter['operator'] == 'prefix')) {
final v = filter['value'];
if (v != null && v.toString().isNotEmpty) {
prefix = v.toString();
break;
}
}
}
var countQuery = kv.query(isGlobal: isGlobal);
if (prefix != null) {
countQuery = countQuery.prefix(prefix);
}
var dataQuery = kv.query(isGlobal: isGlobal);
if (prefix != null) {
dataQuery = dataQuery.prefix(prefix);
}
if (_sortColumn == 'updated_at') {
dataQuery = _sortAscending
? dataQuery.orderByUpdatedAtAsc()
: dataQuery.orderByUpdatedAtDesc();
} else if (_sortColumn == 'key' && !_sortAscending) {
dataQuery = dataQuery.orderByKeyDesc();
} else {
dataQuery = dataQuery.orderByKeyAsc();
}
dataQuery = dataQuery.limit(_pageSize);
final useCursor = _paginationMode == PaginationMode.cursor &&
cursor != null &&
cursor.isNotEmpty &&
!resetPage;
if (useCursor) {
if (cursor == previousNextCursor) {
_currentPage++;
} else if (cursor == previousPrevCursor && _currentPage > 1) {
_currentPage--;
}
dataQuery = dataQuery.cursor(cursor);
} else {
if (_currentPage > _totalPages && _totalPages > 0) {
_currentPage = _totalPages;
}
if (_currentPage < 1) _currentPage = 1;
dataQuery = dataQuery.offset((_currentPage - 1) * _pageSize);
}
final result = await dataQuery;
if (result.hasErrors) {
logService.add('KV query failed: ${result.message}', LogLevel.error);
}
final rows = result.data.map((record) {
return <String, dynamic>{
'key': record['key'],
'value': _formatKvDisplayValue(record['value']),
'updated_at': record['updated_at'],
};
}).toList();
if (!mounted || currentSeq != _fetchSequence) return;
setState(() {
_tableData = rows;
if (_paginationMode == PaginationMode.cursor) {
_nextCursor = result.nextCursorToken;
_prevCursor = result.prevCursorToken;
} else {
_prevCursor = _currentPage > 1 ? 'prev' : null;
_nextCursor = _currentPage < _totalPages ? 'next' : null;
}
_isDataLoading = false;
_pageInputController.text = _currentPage.toString();
});
final scope = isGlobal ? 'global' : 'space';
final prefixHint = prefix == null ? 'no prefix' : 'prefix=$prefix';
final modeHint = useCursor ? 'cursor' : 'offset page $_currentPage';
final elapsedMs = result.executionTimeMs;
logService.add(
'Query KV ($scope, $prefixHint, $modeHint): '
'fetched ${rows.length} records '
'in ${elapsedMs ?? '?'}ms',
LogLevel.info);
// Trigger asynchronous count
_asyncCalculateKvCount(countQuery, currentSeq);
} catch (e, s) {
logService.add('Error fetching KV data: $e', LogLevel.error);
logService.add('Stacktrace: $s', LogLevel.error);
if (mounted) {
setState(() => _isDataLoading = false);
}
}
}
Future<void> _asyncCalculateKvCount(
KvQueryBuilder countQuery, int sequence) async {
try {
if (mounted && sequence == _fetchSequence) {
setState(() => _isCountCalculating = true);
}
final count = await countQuery.count();
if (!mounted || sequence != _fetchSequence) return;
setState(() {
_totalRecords = count;
_totalPages = (_totalRecords / _pageSize).ceil();
if (_totalPages == 0) _totalPages = 1;
if (_currentPage > _totalPages &&
_paginationMode == PaginationMode.offset) {
_currentPage = _totalPages;
_pageInputController.text = _currentPage.toString();
}
_isCountCalculating = false;
});
} catch (_) {
if (mounted && sequence == _fetchSequence) {
setState(() => _isCountCalculating = false);
}
}
}
void _showBriefSnackBar(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
duration: const Duration(seconds: 2),
),
);
}
void _onLogsChanged() {
// Check if we are at the bottom *before* new logs are added.
bool wasAtBottom = true; // Assume true if we can't check
if (_sheetScrollController != null && _sheetScrollController!.hasClients) {
final pos = _sheetScrollController!.position;
wasAtBottom =
pos.pixels >= pos.maxScrollExtent - 5.0; // Use a small tolerance
}
if (wasAtBottom) {
_logScrollToBottom();
}
// After logs are added, the scroll extent might change, so re-evaluate button states.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _logScrollListener();
});
}
void _logScrollListener() {
if (_sheetScrollController == null || !_sheetScrollController!.hasClients) {
return;
}
final position = _sheetScrollController!.position;
_isAtBottom = position.pixels >= position.maxScrollExtent - 5.0;
final atTop = position.pixels <= position.minScrollExtent;
final canScrollUp = !atTop;
final canScrollDown = !_isAtBottom;
if (_logCanScrollUp != canScrollUp || _logCanScrollDown != canScrollDown) {
if (mounted) {
setState(() {
_logCanScrollUp = canScrollUp;
_logCanScrollDown = canScrollDown;
});
}
}
}
void _logScrollToTop() {
if (_sheetScrollController != null && _sheetScrollController!.hasClients) {
_sheetScrollController!.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
}
void _logScrollToBottom() {
// Use a post-frame callback to ensure the list has been rebuilt.
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_sheetScrollController != null &&
_sheetScrollController!.hasClients) {
_sheetScrollController!.animateTo(
_sheetScrollController!.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
});
}
void _updateOperationInfo(String info) {
if (mounted) {
setState(() {
_lastOperationInfo = info;
});
}
}
Future<void> _initializeDatabase() async {
setState(() {
_isInitializing = true;
_lastOperationInfo = 'Initializing Database...';
});
// Exclude getApplicationDocumentsDirectory time from stats
// because that's an OS/Flutter limitation, not the db engine
final dbPath = await widget.example.getDbPath();
final stopwatch = Stopwatch()..start();
await widget.example.initialize(dbPath: dbPath);
stopwatch.stop();
if (mounted) {
setState(() {
_isDbInitialized = true;
_isInitializing = false;
_lastOperationInfo =
'DB Initialized: ${stopwatch.elapsedMilliseconds}ms';
_selectedSpace = widget.example.db.currentSpaceName ?? 'default';
});
// Fetch data if the data view is active
if (_selectedView == AppView.dataView) {
await _fetchTableData(resetPage: true);
}
}
}
Widget _buildActionButton({
required String text,
IconData? icon,
VoidCallback? onPressed,
}) {
return ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: const Color.fromARGB(255, 10, 150, 210),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
elevation: 1.5,
shadowColor: const Color.fromARGB(100, 10, 150, 210),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(icon, size: 18, color: Colors.white),
const SizedBox(width: 6),
],
Text(
text,
textAlign: TextAlign.center,
style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14),
maxLines: 1,
overflow: TextOverflow.visible,
),
],
),
);
}
Widget _buildFilterButton(
String text, LogLevel? type, int count, BuildContext context) {
if (count == 0 && type != null) {
// Don't show the button if there are no logs of this type (except for 'All')
return const SizedBox.shrink();
}
final isSelected = _selectedLogLevel == type;
final Color backgroundColor;
final Color foregroundColor;
final double elevation;
final Color? shadowColor;
Color? countColor;
if (isSelected) {
backgroundColor = const Color.fromARGB(255, 10, 150, 210);
foregroundColor = Colors.white;
elevation = 2;
shadowColor = const Color.fromARGB(102, 6, 126, 177);
} else {
backgroundColor = const Color.fromARGB(255, 227, 232, 235);
foregroundColor = Theme.of(context).colorScheme.onSecondaryContainer;
elevation = 0;
shadowColor = null;
// Set count color for non-selected buttons
if (type == LogLevel.critical) {
countColor = Colors.red.shade900;
} else if (type == LogLevel.error) {
countColor = Colors.red;
} else if (type == LogLevel.warn) {
countColor = Colors.orange;
}
}
return ElevatedButton(
onPressed: () {
setState(() {
_selectedLogLevel = type;
});
},
style: ElevatedButton.styleFrom(
backgroundColor: backgroundColor,
foregroundColor: foregroundColor,
elevation: elevation,
shadowColor: shadowColor,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: RichText(
text: TextSpan(
style: TextStyle(
color: foregroundColor,
fontWeight: FontWeight.normal,
),
children: [
TextSpan(text: '$text ('),
TextSpan(
text: '$count',
style: TextStyle(
fontWeight: FontWeight.bold,
color:
countColor, // This will be null for selected, which is fine
),
),
const TextSpan(text: ')'),
],
),
),
);
}
Color _getLogColor(LogLevel type) {
switch (type) {
case LogLevel.critical:
return Colors.red.shade900;
case LogLevel.error:
return Colors.red;
case LogLevel.warn:
return Colors.orange;
case LogLevel.debug:
return Colors.blueAccent;
case LogLevel.info:
return Colors.black;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
resizeToAvoidBottomInset: false,
appBar: AppBar(
backgroundColor: Colors.white,
surfaceTintColor: Colors.transparent,
elevation: 0,
automaticallyImplyLeading: false,
titleSpacing: 0,
title: const SizedBox.shrink(),
flexibleSpace: SafeArea(
bottom: false,
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _kContentMaxWidth),
child: SizedBox(
width: double.infinity,
height: kToolbarHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Image.asset(
'assets/logo-tostore.png',
height: 36.0,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return const Text(
'ToStore',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Color(0xFF006CC3),
),
);
},
),
const SizedBox(width: 12),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: const Color(0xFFE2E8F0),
width: 0.8,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 6.5,
height: 6.5,
decoration: const BoxDecoration(
color: Color(0xFF10B981),
shape: BoxShape.circle,
),
),
const SizedBox(width: 5),
const Text(
'DEMO',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: Color(0xFF006CC3),
letterSpacing: 1.2,
),
),
],
),
),
const Spacer(),
_buildMoreActionsButton(),
],
),
),
),
),
),
),
bottom: PreferredSize(
preferredSize: const Size.fromHeight(1.0),
child: Container(
color: const Color(0xFFE2E8F0),
height: 1.0,
),
),
),
body: SafeArea(
top: false,
child: Stack(
children: [
// Main Content
Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _kContentMaxWidth),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildViewToggle(),
Expanded(
child: _isInitializing
? _buildInitializingView()
: PageView(
controller: _pageViewController,
onPageChanged: (index) {
final newView = AppView.values[index];
if (_selectedView != newView) {
setState(() {
_selectedView = newView;
});
// If switching to Data View, always refresh the data
// to ensure it's not stale after tests.
if (newView == AppView.dataView) {
_fetchTableData(resetPage: true);
}
}
},
children: [
_buildDataView(),
_buildTestsView(),
],
),
),
// Reserve space for collapsed log (+ watch strip when live).
SizedBox(
height: _hasRecordWatch
? _kBottomChromeHeight * 2
: _kBottomChromeHeight,
),
],
),
),
),
// Draggable Log Panel
_buildResizableLogPanel(),
// Sit on top of the log sheet so the live strip is never covered.
if (_hasRecordWatch) _buildRecordWatchOverlay(),
],
),
),
);
}
/// Positions the PK watch strip just above the current log panel height.
Widget _buildRecordWatchOverlay() {
return ListenableBuilder(
listenable: _logPanelController,
builder: (context, _) {
return LayoutBuilder(
builder: (context, constraints) {
final logFraction =
_logPanelController.isAttached ? _logPanelController.size : 0.1;
final bottom = constraints.maxHeight * logFraction;
return Align(
alignment: Alignment.bottomCenter,
child: Padding(
padding: EdgeInsets.only(bottom: bottom),
child: ConstrainedBox(
constraints:
const BoxConstraints(maxWidth: _kContentMaxWidth),
child: Material(
elevation: 6,
shadowColor: Colors.black26,
child: _buildRecordWatchStrip(),
),
),
),
);
},
);
},
);
}
Widget _buildResizableLogPanel() {
return DraggableScrollableSheet(
controller: _logPanelController,
initialChildSize: 0.1,
minChildSize: 0.1,
maxChildSize: 0.8,
builder: (BuildContext context, ScrollController scrollController) {
// The builder provides a new scrollController instance on each rebuild.
// We must manage our listener accordingly.
if (_sheetScrollController != scrollController) {
_sheetScrollController?.removeListener(_logScrollListener);
_sheetScrollController = scrollController;
_sheetScrollController?.addListener(_logScrollListener);
}
// Outer layer keeps shadow (unclipped); ClipRRect restores top
// rounded corners so opaque log content cannot square them off.
const panelRadius = BorderRadius.only(
topLeft: Radius.circular(16.0),
topRight: Radius.circular(16.0),
);
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _kContentMaxWidth),
child: Container(
width: double.infinity,
decoration: BoxDecoration(
borderRadius: panelRadius,
boxShadow: [
BoxShadow(
blurRadius: 16.0,
color: Colors.black.withAlpha(20),
offset: const Offset(0, -4),
),
],
),
child: ClipRRect(
borderRadius: panelRadius,
child: Container(
decoration: const BoxDecoration(
color: Colors.white,
border: Border(
top: BorderSide(color: Color(0xFFE2E8F0), width: 1),
),
),
child: _buildLogPanel(scrollController),
),
),
),
),
);
},
);
}
Widget _buildInitializingView() {
return const Center(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 16.0, vertical: 24),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(width: 20),
Text('Initializing...'),
],
),
),
);
}
Widget _buildViewToggle() {
final isDataView = _selectedView == AppView.dataView;
return Padding(
padding: const EdgeInsets.only(top: 16.0, bottom: 12.0),
child: Center(
child: Container(
width: 252.0,
height: 42.0,
padding: const EdgeInsets.all(3.0),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(21.0),
border: Border.all(color: const Color(0xFFE2E8F0), width: 1.0),
),
child: Stack(
children: [
// Sliding Symmetrical White Pill Card
AnimatedAlign(
alignment:
isDataView ? Alignment.centerLeft : Alignment.centerRight,
duration: const Duration(milliseconds: 220),
curve: Curves.easeInOutCubic,
child: Container(
width: 121.0,
height: 34.0,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(17.0),
border:
Border.all(color: const Color(0xFFCBD5E1), width: 0.8),
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(12),
blurRadius: 4,
offset: const Offset(0, 1.5),
),
],
),
),
),
// Interactive Text Buttons
Row(
children: [
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (!isDataView) {
_pageViewController.animateToPage(
0,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
);
}
},
child: Center(
child: Text(
'Data View',
style: TextStyle(
fontSize: 13.5,
fontWeight:
isDataView ? FontWeight.w700 : FontWeight.w600,
color: isDataView
? const Color.fromARGB(255, 10, 150, 210)
: const Color(0xFF64748B),
),
),
),
),
),
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (isDataView) {
_pageViewController.animateToPage(
1,
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
);
}
},
child: Center(
child: Text(
'Tests',
style: TextStyle(
fontSize: 13.5,
fontWeight:
!isDataView ? FontWeight.w700 : FontWeight.w600,
color: !isDataView
? const Color.fromARGB(255, 10, 150, 210)
: const Color(0xFF64748B),
),
),
),
),
),
],
),
],
),
),
),
);
}
Widget _buildDataView() {
return Column(
children: [
// Header with record count and actions
_buildDataHeader(),
_buildActiveFiltersDisplay(),
const Divider(height: 1),
// Data Table
if (_isDataLoading)
const Expanded(child: Center(child: CircularProgressIndicator()))
else if (_tableData.isEmpty)
const Expanded(
child: Center(
child: Text('No records found.'),
),
)
else
_buildDataTable(),
// Pagination Controls
_buildPaginationControls(),
// Add padding at the bottom to avoid being obscured by the log panel
const SizedBox(height: 20),
],
);
}
/// Bar above the log panel: live PK watch via [QueryBuilder.watch].
Widget _buildRecordWatchStrip() {
final title = _watchMissing
? 'MISSING $_watchTable.$_watchPkField=$_watchPkValue'
: 'LIVE $_watchTable.$_watchPkField=$_watchPkValue';
final body = _watchMissing
? 'No row in current space (deleted or never existed here). '
'Edits in another space with the same id will appear after switch.'
: (_watchRow == null
? 'Waiting for first watch emission…'
: _watchRow!.entries
.map((e) => '${e.key}:${e.value ?? 'NULL'}')
.join(' · '));
return ColoredBox(
color: _watchMissing ? const Color(0xFFFFF7ED) : const Color(0xFFF5F3FF),
child: SizedBox(
height: _kBottomChromeHeight,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Row(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 220),
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _watchMissing
? const Color(0xFFEA580C)
: (_watchPulse.isEven
? const Color(0xFF7C3AED)
: const Color(0xFF22C55E)),
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
color: _watchMissing
? const Color(0xFF9A3412)
: const Color(0xFF5B21B6),
),
),
const SizedBox(height: 2),
Text(
body,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 12,
color: Color(0xFF334155),
),
),
],
),
),
IconButton(
tooltip: 'Stop watch',
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () => unawaited(_stopRecordWatch()),
icon: const Icon(Icons.close, size: 18),
),
],
),
),
),
);
}
Future<void> _offerWatchSelectedRecord() async {
if (_isKvMode || _primaryKey == null || _selectedRows.length != 1) {
return;
}
final pk = _selectedRows.first;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Watch this primary key?'),
content: Text(
'Start a live query().watch() on\n'
'$_selectedTable.$_primaryKey = $pk\n\n'
'A strip above the log panel will show the row and update on '
'modify/delete. After switchSpace, the same id in the new space '
'keeps driving this watch.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Start watch'),
),
],
),
);
if (confirmed == true && mounted) {
await _startRecordWatch(
table: _selectedTable,
pkField: _primaryKey!,
pkValue: pk,
);
}
}
Future<void> _startRecordWatch({
required String table,
required String pkField,
required dynamic pkValue,
}) async {
await _stopRecordWatch(notify: false);
if (!mounted) return;
setState(() {
_watchTable = table;
_watchPkField = pkField;
_watchPkValue = pkValue;
_watchRow = null;
_watchMissing = false;
_watchPulse = 0;
});
final stream =
widget.example.db.query(table).where(pkField, '=', pkValue).watch();
_watchSub = stream.listen(
(rows) {
if (!mounted) return;
setState(() {
_watchPulse++;
if (rows.isEmpty) {
_watchMissing = true;
_watchRow = null;
} else {
_watchMissing = false;
_watchRow = Map<String, dynamic>.from(rows.first);
}
});
logService.add(
rows.isEmpty
? 'Watch $_watchTable.$_watchPkField=$_watchPkValue → empty '
'(missing in current space)'
: 'Watch $_watchTable.$_watchPkField=$_watchPkValue → '
'${rows.first}',
LogLevel.info,
);
},
onError: (Object e, StackTrace st) {
logService.add('Watch error: $e', LogLevel.error);
},
);
logService.add(
'Started live watch on $table.$pkField=$pkValue',
LogLevel.info,
);
}
Future<void> _stopRecordWatch({bool notify = true}) async {
await _watchSub?.cancel();
_watchSub = null;
if (!mounted) return;
if (_watchTable == null && _watchPkValue == null) return;
setState(() {
_watchTable = null;
_watchPkField = null;
_watchPkValue = null;
_watchRow = null;
_watchMissing = false;
});
if (notify) {
logService.add('Stopped live record watch', LogLevel.info);
}
}
Widget _buildDataHeader() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Table Selector Dropdown Pill
Container(
height: 36.0,
padding: const EdgeInsets.symmetric(horizontal: 10.0),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(8.0),
border:
Border.all(color: const Color(0xFFE2E8F0), width: 1.0),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _selectedTable,
icon: const Icon(
Icons.keyboard_arrow_down_rounded,
size: 20,
color: Color(0xFF64748B),
),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFF0F172A),
),
dropdownColor: Colors.white,
focusColor: Colors.transparent,
borderRadius: BorderRadius.circular(10),
items: _tableNames.map((String tableName) {
return DropdownMenuItem<String>(
value: tableName,
child: Text(tableName),
);
}).toList(),
onChanged: (String? newTable) {
if (newTable != null && newTable != _selectedTable) {
setState(() {
_selectedTable = newTable;
_activeFilters.clear();
});
_fetchTableData(resetPage: true);
}
},
),
),
),
Text(
_isCountCalculating
? (_totalRecords > 0
? '$_totalRecords Records (updating...)'
: 'Loading count...')
: (_isCountLimited
? '≥$_totalRecords Records ($_selectedSpace)'
: '$_totalRecords Records ($_selectedSpace)'),
style: Theme.of(context).textTheme.bodyMedium),
],
),
const SizedBox(height: 8),
// Action Buttons
Wrap(
alignment: WrapAlignment.center,
spacing: 8.0,
runSpacing: 8.0,
children: [
if (_isKvMode) ...[
ElevatedButton.icon(
onPressed: _isDataLoading ? null : _showKvBatchAddDialog,
icon: const Icon(Icons.add, size: 16),
label: const Text('Batch Add'),
style: ElevatedButton.styleFrom(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
backgroundColor: const Color.fromARGB(255, 10, 150, 210),
foregroundColor: Colors.white,
textStyle: const TextStyle(fontSize: 14),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
),
ElevatedButton.icon(
onPressed: _selectedRows.isEmpty || _isDataLoading
? null
: _confirmDeleteSelected,
icon: const Icon(Icons.delete, size: 16),
label: Text('Del(${_selectedRows.length})'),
style: ElevatedButton.styleFrom(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
backgroundColor:
_selectedRows.isEmpty ? Colors.grey : Colors.red,
foregroundColor: Colors.white,
textStyle: const TextStyle(fontSize: 14),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
),
] else ...[
ElevatedButton.icon(
onPressed: _isDataLoading ? null : _showAddDataDialog,
icon: const Icon(Icons.add, size: 16),
label: const Text('Add'),
style: ElevatedButton.styleFrom(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
backgroundColor: const Color.fromARGB(255, 10, 150, 210),
foregroundColor: Colors.white,
textStyle: const TextStyle(fontSize: 14),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
),
ElevatedButton.icon(
onPressed: _selectedRows.isEmpty || _isDataLoading
? null
: _showBatchUpdateDialog,
icon: const Icon(Icons.edit, size: 16),
label: const Text('Modify'),
style: ElevatedButton.styleFrom(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
backgroundColor:
_selectedRows.isEmpty ? Colors.grey : Colors.green,
foregroundColor: Colors.white,
textStyle: const TextStyle(fontSize: 14),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
),
ElevatedButton.icon(
onPressed: _selectedRows.isEmpty || _isDataLoading
? null
: _confirmDeleteSelected,
icon: const Icon(Icons.delete, size: 16),
label: Text('Del(${_selectedRows.length})'),
style: ElevatedButton.styleFrom(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
backgroundColor:
_selectedRows.isEmpty ? Colors.grey : Colors.red,
foregroundColor: Colors.white,
textStyle: const TextStyle(fontSize: 14),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
),
],
PopupMenuButton<String>(
icon: Icon(
Icons.more_horiz_outlined,
color: _activeFilters.isEmpty ? null : Colors.orange.shade700,
),
tooltip: 'Advanced Actions',
onSelected: (value) {
if (value == 'kv_set') {
_showKvSetDialog();
} else if (value == 'kv_get') {
_showKvGetDialog();
} else if (value == 'filter') {
_showFilterDialog();
} else if (value == 'watch_pk') {
unawaited(_offerWatchSelectedRecord());
} else if (value == 'custom_delete') {
_showCustomDeleteDialog();
} else if (value == 'clear_current_table') {
_confirmClearCurrentTable();
} else if (value == 'vector_search') {
_showVectorSearchBenchmarkDialog();
}
},
itemBuilder: (context) => [
if (_isKvMode) ...[
const PopupMenuItem(
value: 'kv_set',
child: Row(
children: [
Icon(Icons.edit_note, size: 18, color: Colors.green),
SizedBox(width: 8),
Text('Set Key'),
],
),
),
const PopupMenuItem(
value: 'kv_get',
child: Row(
children: [
Icon(Icons.search, size: 18, color: Colors.indigo),
SizedBox(width: 8),
Text('Get Key'),
],
),
),
const PopupMenuDivider(),
],
PopupMenuItem(
value: 'filter',
child: Row(
children: [
const Icon(Icons.filter_alt_outlined, size: 18),
const SizedBox(width: 8),
Text(
_isKvMode ? 'Filter by Key Prefix' : 'Filter Data'),
],
),
),
if (!_isKvMode) ...[
PopupMenuItem(
value: 'watch_pk',
enabled: _selectedRows.length == 1 &&
_primaryKey != null &&
!_isDataLoading,
child: Row(
children: [
Icon(
Icons.visibility_outlined,
size: 18,
color: _selectedRows.length == 1
? const Color(0xFF7C3AED)
: Colors.grey,
),
const SizedBox(width: 8),
Text(
_selectedRows.length == 1
? 'Watch PK ${_selectedRows.first}'
: 'Watch PK (select 1 row)',
),
],
),
),
const PopupMenuDivider(),
const PopupMenuItem(
value: 'custom_delete',
child: Row(
children: [
Icon(Icons.playlist_remove, size: 18),
SizedBox(width: 8),
Text('Custom Delete'),
],
),
),
if (_hasVectorSupport) const PopupMenuDivider(),
if (_hasVectorSupport)
const PopupMenuItem(
value: 'vector_search',
child: Row(
children: [
Icon(Icons.query_stats,
size: 18, color: Color(0xff0aa6e8)),
SizedBox(width: 8),
Text('Vector Search'),
],
),
),
],
const PopupMenuDivider(),
PopupMenuItem(
value: 'clear_current_table',
child: Row(
children: [
const Icon(Icons.cleaning_services_rounded, size: 18),
const SizedBox(width: 8),
Text(_isKvMode ? 'Clear KV' : 'Clear Table'),
],
),
),
],
),
],
)
],
),
);
}
Widget _buildActiveFiltersDisplay() {
if (_activeFilters.isEmpty) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 8, 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Wrap(
spacing: 6.0,
runSpacing: 6.0,
children: _activeFilters.map((filter) {
return Chip(
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
padding: const EdgeInsets.all(4),
label: Text(
"'${filter['field']}' ${filter['operator']} '${filter['value']}'"),
deleteIcon: const Icon(Icons.close, size: 14),
onDeleted: () {
setState(() {
_activeFilters.remove(filter);
});
_fetchTableData(resetPage: true);
},
);
}).toList(),
),
),
IconButton(
tooltip: 'Clear All Filters',
icon: const Icon(Icons.close_outlined, color: Colors.redAccent),
onPressed: () {
setState(() {
_activeFilters.clear();
});
_fetchTableData(resetPage: true);
},
),
],
),
);
}
Widget _buildDataTable() {
if (_tableColumns.isEmpty) {
return const Expanded(child: Center(child: Text('No records found.')));
}
return Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.vertical,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: DataTable(
sortColumnIndex: _sortColumn == null
? null
: _tableColumns.indexOf(_sortColumn!),
sortAscending: _sortAscending,
showCheckboxColumn: _primaryKey != null,
columns: [
for (final colName in _tableColumns)
DataColumn(
label: Text(colName),
onSort: (columnIndex, ascending) {
setState(() {
if (_sortColumn == _tableColumns[columnIndex]) {
if (_sortAscending) {
_sortAscending = false;
} else {
_sortColumn = null;
}
} else {
_sortColumn = _tableColumns[columnIndex];
_sortAscending = true;
}
});
_fetchTableData();
},
),
],
rows: _tableData.map((row) {
final pkValue = _primaryKey != null ? row[_primaryKey] : null;
return DataRow(
selected: pkValue != null && _selectedRows.contains(pkValue),
onSelectChanged: pkValue == null
? null
: (isSelected) {
setState(() {
if (isSelected ?? false) {
_selectedRows.add(pkValue);
} else {
_selectedRows.remove(pkValue);
}
});
},
cells: [
for (final colName in _tableColumns)
DataCell(
Text(
'${row[colName] ?? 'NULL'}',
overflow: TextOverflow.ellipsis,
),
onLongPress: () {
if (_primaryKey != null && row[_primaryKey] != null) {
_showEditRowDialog(row);
}
},
),
],
);
}).toList(),
onSelectAll: (isSelected) {
if (_primaryKey == null) return;
setState(() {
if (isSelected ?? false) {
for (final row in _tableData) {
_selectedRows.add(row[_primaryKey]);
}
} else {
for (final row in _tableData) {
_selectedRows.remove(row[_primaryKey]);
}
}
});
},
),
),
),
);
}
Widget _buildPaginationControls() {
if (_paginationMode == PaginationMode.cursor) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.first_page, size: 20),
tooltip: 'Jump to First Page',
onPressed: _prevCursor != null
? () => _fetchTableData(resetPage: true)
: null,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
),
const SizedBox(width: 8),
ElevatedButton.icon(
icon: const Icon(Icons.chevron_left, size: 16),
label: const Text('Prev'),
style: ElevatedButton.styleFrom(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: _prevCursor != null
? () => _fetchTableData(cursor: _prevCursor)
: null,
),
const SizedBox(width: 16),
ElevatedButton.icon(
icon: const Icon(Icons.chevron_right, size: 16),
label: const Text('Next'),
style: ElevatedButton.styleFrom(
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: _nextCursor != null
? () => _fetchTableData(cursor: _nextCursor)
: null,
),
],
),
);
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0),
child: Wrap(
alignment: WrapAlignment.center,
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 2.0, // Reduced space between items
runSpacing: 8.0,
children: [
IconButton(
icon: const Icon(Icons.first_page),
tooltip: 'First Page',
onPressed: _currentPage > 1 ? () => _goToPage(1) : null,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
),
IconButton(
icon: const Icon(Icons.chevron_left),
tooltip: 'Previous Page',
onPressed:
_currentPage > 1 ? () => _goToPage(_currentPage - 1) : null,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
),
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Text('Page'),
const SizedBox(width: 4), // Reduced space
SizedBox(
width: 50,
child: TextField(
controller: _pageInputController,
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(
isDense: true,
border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric(horizontal: 8),
),
onSubmitted: (value) {
final page = int.tryParse(value);
if (page != null) {
_goToPage(page);
}
},
),
),
const SizedBox(width: 4), // Reduced space
Text('of $_totalPages'),
],
),
IconButton(
icon: const Icon(Icons.chevron_right),
tooltip: 'Next Page',
onPressed: _currentPage < _totalPages
? () => _goToPage(_currentPage + 1)
: null,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
),
IconButton(
icon: const Icon(Icons.last_page),
tooltip: 'Last Page',
onPressed: _currentPage < _totalPages
? () => _goToPage(_totalPages)
: null,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
),
],
),
);
}
void _goToPage(int page) {
if (page < 1 || page > _totalPages) return;
setState(() {
_currentPage = page;
});
_fetchTableData();
}
void _showOffsetLimitWarning(int offset, int maxOffset) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.orange),
SizedBox(width: 8),
Text('Offset Limit Reached'),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Current offset: $offset'),
Text('Environment limit: $maxOffset'),
const SizedBox(height: 12),
const Text(
'Deep pagination using Offset is discouraged due to performance costs. '
'Please use Cursor mode for better performance at this depth.',
style: TextStyle(fontWeight: FontWeight.bold),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
setState(() {
_paginationMode = PaginationMode.cursor;
});
_fetchTableData(resetPage: true);
},
child: const Text('Switch to Cursor Mode'),
),
],
),
);
}
Widget _buildTestsView() {
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 200.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
height: 40,
alignment: Alignment.center,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_isTesting) ...[
const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2.0),
),
const SizedBox(width: 12),
],
Expanded(
child: Text(
_lastOperationInfo,
style: Theme.of(context).textTheme.titleMedium,
overflow: TextOverflow.ellipsis,
textAlign:
_isTesting ? TextAlign.start : TextAlign.center,
),
),
],
),
),
const SizedBox(height: 20),
LayoutBuilder(builder: (context, constraints) {
final double calculatedWidth = (constraints.maxWidth - 12) / 2;
final double buttonWidth = constraints.maxWidth < 360
? constraints.maxWidth
: math.min(calculatedWidth, 220.0);
return Wrap(
spacing: 12,
runSpacing: 12,
alignment: WrapAlignment.center,
children: [
SizedBox(
width: buttonWidth,
child: Tooltip(
message: 'Run standardized performance benchmark suite',
child: _buildActionButton(
text: 'Benchmark Test',
icon: Icons.speed_rounded,
onPressed: !_isDbInitialized || _isTesting
? null
: () {
_checkAndExpandLogPanel();
_showBenchmarkDialog();
},
),
),
),
SizedBox(
width: buttonWidth,
child: Tooltip(
message: _isWasmBuild
? 'Concurrency Test is unavailable on WebAssembly builds'
: 'Run configurable concurrency test',
child: _buildActionButton(
text: 'Concurrency Test',
icon: Icons.alt_route_rounded,
onPressed:
!_isDbInitialized || _isTesting || _isWasmBuild
? null
: () {
_checkAndExpandLogPanel();
_showConcurrencyTestDialog();
},
),
),
),
SizedBox(
width: buttonWidth,
child: _buildActionButton(
text: 'Run All Tests',
icon: Icons.play_arrow_rounded,
onPressed: !_isDbInitialized || _isTesting
? null
: () async {
_checkAndExpandLogPanel();
setState(() {
_isTesting = true;
});
try {
final tester = DatabaseTester(
widget.example.db,
logService,
_updateOperationInfo,
);
await tester.runAllTests();
} finally {
if (mounted) {
setState(() {
_isTesting = false;
});
}
_fetchTableData(resetPage: true);
}
},
),
),
],
);
}),
],
),
),
);
}
Widget _buildLogPanel(ScrollController scrollController) {
// We listen to the logs here to dynamically determine the header size.
return ValueListenableBuilder<List<LogEntry>>(
valueListenable: logService.logs,
builder: (context, logs, child) {
final bool hasLogs = logs.isNotEmpty;
const double handleAndTitleHeight = 60.0;
// Use a LayoutBuilder to dynamically calculate the header height
// based on whether the search/filter section is visible.
return LayoutBuilder(
builder: (context, constraints) {
// Create a text painter to measure text height for an accurate calculation.
final textPainter = TextPainter(
text: const TextSpan(text: 'Filter'),
textDirection: TextDirection.ltr,
)..layout();
// Estimate heights for various components.
const double textFieldHeight =
50.0; // Approx height of the TextField
const double paddingAndSpacing = 30.0; // Combined vertical padding
final double buttonHeight =
textPainter.height * 2.5; // Estimated height for filter buttons
final double buttonsSectionHeight =
(constraints.maxWidth < 350) ? buttonHeight * 2 : buttonHeight;
final searchAndFilterHeight =
textFieldHeight + paddingAndSpacing + buttonsSectionHeight;
final totalHeaderHeight =
handleAndTitleHeight + (hasLogs ? searchAndFilterHeight : 0);
return CustomScrollView(
controller: scrollController,
slivers: [
SliverPersistentHeader(
pinned: true,
delegate: _LogPanelHeaderDelegate(
height: totalHeaderHeight,
child: GestureDetector(
onDoubleTap: () {
if (_logPanelController.isAttached) {
final bool isExpanded =
_logPanelController.size > 0.15;
_logPanelController.animateTo(
isExpanded ? 0.1 : 0.8,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
},
child: Container(
color: Theme.of(context).colorScheme.surface,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Drag Handle and Title Row
SizedBox(
height: handleAndTitleHeight,
child: Column(
children: [
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(
top: 6, bottom: 2),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(10),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(
16.0, 0, 12, 6),
child: AnimatedBuilder(
animation: _logPanelController,
builder: (context, child) {
final bool isExpanded =
_logPanelController.size > 0.15;
return Row(
crossAxisAlignment:
CrossAxisAlignment.center,
children: [
const Text(
'Logs',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
const Spacer(),
if (isExpanded) ...[
_buildLogPanelIconButton(
icon: Icons.arrow_upward,
tooltip: 'Scroll to Top',
onPressed: _logCanScrollUp
? _logScrollToTop
: null,
),
_buildLogPanelIconButton(
icon: Icons.arrow_downward,
tooltip: 'Scroll to Bottom',
onPressed: _logCanScrollDown
? _logScrollToBottom
: null,
),
_buildLogPanelIconButton(
icon: Icons.copy_outlined,
iconSize: 20,
tooltip: 'Copy Visible Logs',
onPressed: _copyVisibleLogs,
),
_buildLogPanelIconButton(
icon: Icons
.cleaning_services_rounded,
iconSize: 20,
tooltip: 'Clear Logs',
onPressed: logService.clear,
),
],
_buildLogPanelIconButton(
icon: isExpanded
? Icons.keyboard_arrow_down
: Icons.keyboard_arrow_up,
tooltip: isExpanded
? 'Collapse Logs'
: 'Expand Logs',
onPressed: () {
_logPanelController.animateTo(
isExpanded ? 0.1 : 0.8,
duration: const Duration(
milliseconds: 300),
curve: Curves.easeOut,
);
},
),
],
);
},
),
),
),
const Divider(height: 1),
],
),
),
// Search and Filter section (conditionally shown)
if (hasLogs)
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
mainAxisAlignment:
MainAxisAlignment.spaceEvenly,
children: [
TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search in logs...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController
.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
},
)
: null,
border: OutlineInputBorder(
borderRadius:
BorderRadius.circular(8),
),
enabledBorder: OutlineInputBorder(
borderRadius:
BorderRadius.circular(8),
borderSide: BorderSide(
color: Colors.grey.shade300,
width: 0.8),
),
contentPadding:
const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
),
),
ValueListenableBuilder<List<LogEntry>>(
valueListenable: logService.logs,
builder: (context, logs, child) {
// Counts...
final allCount = logs.length;
final infoCount = logs
.where((log) =>
log.type == LogLevel.info)
.length;
final debugCount = logs
.where((log) =>
log.type == LogLevel.debug)
.length;
final warnCount = logs
.where((log) =>
log.type == LogLevel.warn)
.length;
final errorCount = logs
.where((log) =>
log.type == LogLevel.error)
.length;
final criticalCount = logs
.where((log) =>
log.type == LogLevel.critical)
.length;
return Wrap(
spacing: 8.0,
runSpacing: 8.0,
alignment: WrapAlignment.start,
children: [
_buildFilterButton('All', null,
allCount, context),
_buildFilterButton(
'Info',
LogLevel.info,
infoCount,
context),
_buildFilterButton(
'Debug',
LogLevel.debug,
debugCount,
context),
_buildFilterButton(
'Warn',
LogLevel.warn,
warnCount,
context),
_buildFilterButton(
'Error',
LogLevel.error,
errorCount,
context),
_buildFilterButton(
'Critical',
LogLevel.critical,
criticalCount,
context),
],
);
},
),
],
),
),
),
],
),
),
),
),
),
ValueListenableBuilder<List<LogEntry>>(
valueListenable: logService.logs,
builder: (context, logs, child) {
// ... (filtering logic remains the same)
final filteredByType = _selectedLogLevel == null
? logs
: logs
.where((log) => log.type == _selectedLogLevel)
.toList();
final searchText = _searchController.text.toLowerCase();
final filteredLogs = searchText.isEmpty
? filteredByType
: filteredByType
.where((log) =>
log.message.toLowerCase().contains(searchText))
.toList();
if (filteredLogs.isEmpty) {
return const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 48),
child: Center(child: Text('No logs to display.')),
),
);
}
return SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: 16.0, vertical: 16.0),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final logEntry = filteredLogs[index];
return Padding(
padding:
const EdgeInsets.symmetric(vertical: 4.0),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onLongPress: () => _copySingleLog(logEntry),
child: Text(
logEntry.message,
style: TextStyle(
fontSize: 12,
color: _getLogColor(logEntry.type),
),
),
),
);
},
childCount: filteredLogs.length,
),
),
);
},
),
],
);
},
);
},
);
}
PopupMenuButton<String> _buildMoreActionsButton() {
return PopupMenuButton<String>(
onSelected: (value) async {
if (value.startsWith('switch_space_')) {
final newSpace = value.substring('switch_space_'.length);
if (newSpace != _selectedSpace) {
await widget.example.db
.switchSpace(spaceName: newSpace, keepActive: true);
setState(() {
_selectedSpace = newSpace;
_activeFilters.clear();
});
await _fetchTableData(resetPage: true);
}
return;
}
switch (value) {
case 'set_mode_offset':
setState(() {
_paginationMode = PaginationMode.offset;
});
_fetchTableData(resetPage: true);
break;
case 'set_mode_cursor':
setState(() {
_paginationMode = PaginationMode.cursor;
});
_fetchTableData(resetPage: true);
break;
case 'clear_all_tables':
setState(() {
_isTesting = true;
_lastOperationInfo = 'Clearing all tables...';
});
try {
await widget.example.db.clear(ExampleSchemas.comments.name);
await widget.example.db.clear(ExampleSchemas.posts.name);
await widget.example.db.clear(ExampleSchemas.users.name);
await widget.example.db.clear(ExampleSchemas.settings.name);
await widget.example.db.clear(ExampleSchemas.embeddings.name);
_updateOperationInfo('All tables cleared.');
_fetchTableData(resetPage: true);
} finally {
if (mounted) {
setState(() {
_isTesting = false;
});
}
}
break;
case 'restore_initialization':
_confirmRestoreInitialization();
break;
}
},
itemBuilder: (BuildContext context) {
return [
PopupMenuItem<String>(
value: _paginationMode == PaginationMode.offset
? 'set_mode_cursor'
: 'set_mode_offset',
child: Row(
children: [
Icon(
_paginationMode == PaginationMode.offset
? Icons.ads_click
: Icons.format_list_numbered,
size: 20,
color: Colors.blue,
),
const SizedBox(width: 12),
Text(_paginationMode == PaginationMode.offset
? 'Switch to Cursor Mode'
: 'Switch to Offset Mode'),
],
),
),
const PopupMenuDivider(),
const PopupMenuItem<String>(
enabled: false,
child: Row(
children: [
Icon(Icons.storage, size: 20, color: Colors.grey),
SizedBox(width: 12),
Text('Switch Space',
style: TextStyle(fontWeight: FontWeight.bold)),
],
),
),
..._spaceNames.map((spaceName) {
return CheckedPopupMenuItem<String>(
value: 'switch_space_$spaceName',
checked: _selectedSpace == spaceName,
child: Text(spaceName),
);
}),
const PopupMenuDivider(),
const PopupMenuItem<String>(
value: 'clear_all_tables',
child: Row(
children: [
Icon(
Icons.delete_sweep,
size: 20,
),
SizedBox(width: 12),
Text('Clear All Tables'),
],
),
),
const PopupMenuDivider(),
const PopupMenuItem<String>(
value: 'restore_initialization',
child: Row(
children: [
Icon(
Icons.refresh_rounded,
size: 20,
),
SizedBox(width: 12),
Text('Restore Initialization'),
],
),
),
];
},
);
}
Future<void> _showBenchmarkDialog() async {
final config = await showDialog<BenchmarkConfig>(
context: context,
barrierDismissible: true,
builder: (context) => BenchmarkDialog(
lastSummary: _lastBenchmarkSummary,
initialConfig: _benchmarkConfig,
),
);
if (!mounted || config == null) return;
_benchmarkConfig = config;
_updateOperationInfo('Starting Benchmark Suite...');
setState(() {
_isTesting = true;
});
BenchmarkSummary? resultSummary;
try {
final runner = BenchmarkRunner(
widget.example.db,
logService,
_updateOperationInfo,
);
resultSummary = await runner.runBenchmark(config);
if (mounted) {
setState(() {
_lastBenchmarkSummary = resultSummary;
});
}
} catch (e) {
logService.add('Benchmark failed: $e', LogLevel.error);
_updateOperationInfo('❌ Benchmark Failed');
} finally {
if (mounted) {
setState(() {
_isTesting = false;
});
_fetchTableData(resetPage: true);
if (resultSummary != null) {
// Auto open results dialog upon completion
_showBenchmarkDialog();
}
}
}
}
Future<void> _showConcurrencyTestDialog() async {
if (_isWasmBuild) {
logService.add(
'❌ Concurrency Test is disabled on WebAssembly builds.',
LogLevel.warn,
);
_updateOperationInfo('❌ Concurrency Test Disabled on WebAssembly');
return;
}
final config = await showDialog<Map<String, Map<String, int>>>(
context: context,
barrierDismissible: false,
builder: (context) => const ConcurrencyTestDialog(),
);
if (!mounted) return;
if (config != null) {
_updateOperationInfo('Running Custom Concurrency Test...');
setState(() {
_isTesting = true;
});
try {
final tester = DatabaseTester(
widget.example.db,
logService,
_updateOperationInfo,
);
final success = await tester.runConfigurableConcurrencyTest(config);
if (success) {
_updateOperationInfo('✅ Custom Concurrency Test Passed');
} else {
_updateOperationInfo('❌ Custom Concurrency Test Failed');
}
} finally {
if (mounted) {
setState(() {
_isTesting = false;
});
_fetchTableData(resetPage: true);
}
}
}
}
Future<void> _showVectorSearchBenchmarkDialog() async {
final result = await showDialog<Map<String, int>>(
context: context,
builder: (context) => const VectorSearchDialog(),
);
if (!mounted) return;
if (result != null) {
final iterations = result['iterations'] ?? 1;
final topK = result['topK'] ?? 10;
final searchDepth = result['searchDepth'] ?? 50;
setState(() {
_isTesting = true;
_lastOperationInfo =
'Running $iterations vector search iterations (Top-$topK, depth=$searchDepth)...';
});
try {
await widget.example.vectorSearchBenchmark(
_selectedTable,
iterations,
topK,
searchDepth: searchDepth,
);
} catch (e) {
logService.add('Benchmark failed: $e', LogLevel.error);
_updateOperationInfo('❌ Vector search benchmark failed.');
} finally {
if (mounted) {
setState(() {
_isTesting = false;
});
}
}
}
}
Future<void> _showAddDataDialog() async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (context) => AddDataDialog(
defaultCount: 10000,
tableName: _selectedTable,
db: widget.example.db,
),
);
if (!mounted) return;
if (result != null) {
final count = result['count'] as int;
final method = result['method'] as InsertMethod;
final foreignKeyValues =
result['foreignKeyValues'] as Map<String, dynamic>?;
final foreignKeyModes =
result['foreignKeyModes'] as Map<String, ForeignKeyMode>?;
final foreignKeyIdLists =
result['foreignKeyIdLists'] as Map<String, List<dynamic>>?;
if (count <= 0) return;
setState(() {
_isTesting = true; // Use the tests view's testing flag
_isDataLoading = true;
_lastOperationInfo =
'Adding $count records (${method == InsertMethod.batch ? 'batch' : 'one-by-one'})...';
});
int elapsed = -1;
try {
if (method == InsertMethod.batch) {
// Use the existing benchmark logic for batch adding
elapsed = await widget.example.addExamples(
_selectedTable,
count,
foreignKeyValues: foreignKeyValues,
foreignKeyModes: foreignKeyModes,
foreignKeyIdLists: foreignKeyIdLists,
);
} else {
// Use the existing benchmark logic for one-by-one adding
elapsed = await widget.example.addExamplesOneByOne(
_selectedTable,
count,
foreignKeyValues: foreignKeyValues,
foreignKeyModes: foreignKeyModes,
foreignKeyIdLists: foreignKeyIdLists,
);
}
} catch (e, s) {
logService.add('Failed to add data: $e', LogLevel.error);
logService.add('Stacktrace: $s', LogLevel.error);
// 'elapsed' remains -1, indicating failure.
}
if (!mounted) return;
// Show a user-friendly SnackBar based on the operation result.
if (elapsed >= 0) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Added $count records in ${elapsed}ms'),
duration: const Duration(seconds: 3),
backgroundColor: Colors.green,
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Failed to add data. Please check logs for details.'),
duration: Duration(seconds: 4),
backgroundColor: Colors.red,
),
);
}
setState(() {
_isTesting = false;
});
await _fetchTableData(
resetPage: true); // Refresh the view and go to page 1
}
}
Future<void> _confirmDeleteSelected() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirm Deletion'),
content: Text(
'Are you sure you want to delete ${_selectedRows.length} selected record(s)?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Delete'),
),
],
),
);
if (confirmed ?? false) {
if (_primaryKey == null) {
logService.add('Cannot delete without a primary key.', LogLevel.error);
return;
}
setState(() {
_isDataLoading = true;
_lastOperationInfo = 'Deleting ${_selectedRows.length} records...';
});
try {
if (_isKvMode) {
final keys = _selectedRows.map((e) => e.toString()).toList();
final result = await widget.example.db.kv
.removeKeys(keys, isGlobal: _isKvGlobal);
logService.add(
'Removed ${result.successCount} of ${keys.length} KV key(s).',
!result.hasErrors ? LogLevel.info : LogLevel.warn);
if (result.failedCount > 0) {
logService.add(
'Failed to remove ${result.failedCount} KV key(s). Error: ${_dbResultErrorMessage(result)}',
LogLevel.error);
}
} else {
final result = await widget.example.db
.delete(_selectedTable)
.whereIn(_primaryKey!, _selectedRows.toList());
logService.add(
'Deleted ${result.successCount} of ${_selectedRows.length} records.',
!result.hasErrors ? LogLevel.info : LogLevel.warn);
if (result.failedCount > 0) {
logService.add(
'Failed to delete ${result.failedCount} records. Error: ${_dbResultErrorMessage(result)}',
LogLevel.error);
}
}
} catch (e, s) {
logService.add('Failed to delete data: $e', LogLevel.error);
logService.add('Stacktrace: $s', LogLevel.error);
}
_selectedRows.clear();
await _fetchTableData(); // Refresh the view
}
}
Future<void> _showEditRowDialog(Map<String, dynamic> rowData) async {
if (_isKvMode) {
await _showKvSetDialog(
initialKey: rowData['key']?.toString(),
initialValue: rowData['value']?.toString(),
);
return;
}
final schema = await widget.example.db.getTableSchema(_selectedTable);
if (schema == null) {
logService.add('Cannot edit row: Schema not found for $_selectedTable.',
LogLevel.warn);
return;
}
if (!mounted) return;
final Map<String, dynamic>? updatedData = await showDialog(
context: context,
builder: (context) => EditRowDialog(
schema: schema,
initialData: rowData,
),
);
if (!mounted) return;
if (updatedData != null) {
setState(() {
_isDataLoading = true;
_lastOperationInfo = 'Updating row...';
});
final pkValue = rowData[schema.primaryKeyConfig.name];
try {
final result = await widget.example.db
.update(_selectedTable, updatedData)
.where(schema.primaryKeyConfig.name, '=', pkValue);
if (!result.hasErrors) {
logService.add('Row successfully updated.', LogLevel.info);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Row updated!'),
duration: Duration(seconds: 2),
),
);
}
} else {
final errorMsg = _dbResultErrorMessage(result);
logService.add('Failed to update row: $errorMsg', LogLevel.error);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Error: $errorMsg'),
backgroundColor: Colors.red,
),
);
}
}
} catch (e, s) {
logService.add('Failed to update data: $e', LogLevel.error);
logService.add('Stacktrace: $s', LogLevel.error);
}
await _fetchTableData();
}
}
Future<void> _showBatchUpdateDialog() async {
final schema = await widget.example.db.getTableSchema(_selectedTable);
if (schema == null) {
logService.add(
'Cannot modify rows: Schema not found for $_selectedTable.',
LogLevel.warn);
return;
}
if (!mounted) return;
final Map<String, dynamic>? updateInfo = await showDialog(
context: context,
builder: (context) => BatchUpdateDialog(schema: schema),
);
if (!mounted) return;
if (updateInfo != null) {
final fieldToUpdate = updateInfo['field'] as String;
final newValue = updateInfo['value'];
setState(() {
_isDataLoading = true;
_lastOperationInfo = 'Updating ${_selectedRows.length} records...';
});
try {
final result = await widget.example.db
.update(_selectedTable, {fieldToUpdate: newValue})
.whereIn(_primaryKey!, _selectedRows.toList())
.allowPartialErrors();
final successMsg =
'Successfully updated ${result.successCount} of ${_selectedRows.length} records.';
logService.add(successMsg, LogLevel.info);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(successMsg),
duration: const Duration(seconds: 3),
),
);
}
if (result.failedCount > 0) {
final errorMsg =
'Failed to update ${result.failedCount} records. Error: ${_dbResultErrorMessage(result)}';
logService.add(errorMsg, LogLevel.error);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(errorMsg),
backgroundColor: Colors.red,
duration: const Duration(seconds: 5),
),
);
}
}
} catch (e, s) {
logService.add('Failed to bulk update data: $e', LogLevel.error);
logService.add('Stacktrace: $s', LogLevel.error);
}
_selectedRows.clear();
await _fetchTableData();
}
}
Future<void> _showCustomDeleteDialog() async {
final schema = await widget.example.db.getTableSchema(_selectedTable);
if (schema == null) {
logService.add(
'Cannot perform custom delete: Schema not found for $_selectedTable.',
LogLevel.warn);
return;
}
if (!mounted) return;
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (context) => CustomDeleteDialog(schema: schema),
);
if (!mounted) return;
if (result != null) {
final field = result['field'] as String;
final op = result['operator'] as String;
final value = result['value'];
if (value == null) {
logService.add('Invalid value for custom delete.', LogLevel.warn);
return;
}
if (!mounted) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirm Custom Deletion'),
content: Text(
'Are you sure you want to delete all records from "$_selectedTable" where $field $op $value? This action cannot be undone.'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Delete'),
),
],
),
);
if (!mounted) return;
if (confirmed ?? false) {
setState(() {
_isDataLoading = true;
_lastOperationInfo = 'Deleting records where $field $op $value...';
});
try {
final result = await widget.example.db
.delete(_selectedTable)
.where(field, op, value)
.allowLargeScaleOperation();
logService.add(
'Custom delete affected ${result.successCount} record(s).',
!result.hasErrors ? LogLevel.info : LogLevel.warn);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${result.successCount} record(s) deleted successfully.'),
),
);
}
} catch (e, s) {
logService.add('Failed to perform custom delete: $e', LogLevel.error);
logService.add('Stacktrace: $s', LogLevel.error);
}
await _fetchTableData(resetPage: true);
}
}
}
Future<void> _confirmRestoreInitialization() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Confirm Restore Initialization'),
content: const Text(
'This will delete the entire database and all test data, then re-initialize it. This action cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Restore'),
),
],
),
);
if (confirmed ?? false) {
if (!mounted) return;
setState(() {
_isInitializing = true;
_lastOperationInfo = 'Restoring Initialization...';
});
try {
await widget.example.db.deleteDatabase();
logService.add('Database deleted for restoration.', LogLevel.info);
await _initializeDatabase();
} catch (e, s) {
logService.add('Failed to restore initialization: $e', LogLevel.error);
logService.add('Stacktrace: $s', LogLevel.error);
if (mounted) {
setState(() {
_isInitializing = false;
});
}
}
}
}
Future<void> _showFilterDialog() async {
if (_isKvMode) {
final prefixController = TextEditingController(
text: _activeFilters.isNotEmpty
? (_activeFilters.first['value']?.toString() ?? '')
: '',
);
final prefix = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Filter KV by Key Prefix'),
content: TextField(
controller: prefixController,
decoration: const InputDecoration(
labelText: 'Key prefix',
hintText: _kKvDefaultKeyPrefix,
border: OutlineInputBorder(),
),
autofocus: true,
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(''),
child: const Text('Clear Filter'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () =>
Navigator.of(context).pop(prefixController.text.trim()),
child: const Text('Apply'),
),
],
),
);
prefixController.dispose();
if (!mounted || prefix == null) return;
setState(() {
if (prefix.isEmpty) {
_activeFilters = [];
} else {
_activeFilters = [
{'field': 'key', 'operator': 'prefix', 'value': prefix},
];
}
});
await _fetchTableData(resetPage: true);
return;
}
final schema = await widget.example.db.getTableSchema(_selectedTable);
if (schema == null) {
logService.add('Cannot filter: Schema not found for $_selectedTable.',
LogLevel.warn);
return;
}
if (!mounted) return;
final newFilters = await showDialog<List<Map<String, dynamic>>>(
context: context,
builder: (context) => FilterDialog(
schema: schema,
existingFilters: _activeFilters,
),
);
if (!mounted) return;
if (newFilters != null) {
setState(() {
_activeFilters = newFilters;
});
_fetchTableData(resetPage: true);
}
}
Future<void> _confirmClearCurrentTable() async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(_isKvMode ? 'Confirm Clear KV' : 'Confirm Clear Table'),
content: Text(
_isKvMode
? 'Are you sure you want to clear all key-value pairs in ${_isKvGlobal ? 'global' : 'current space'} KV store?'
: 'Are you sure you want to clear the current table?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Clear'),
),
],
),
);
if (!mounted) return;
if (confirmed ?? false) {
setState(() {
_isDataLoading = true;
_lastOperationInfo =
_isKvMode ? 'Clearing KV store...' : 'Clearing current table...';
});
try {
if (_isKvMode) {
await widget.example.db.kv.clear(isGlobal: _isKvGlobal);
logService.add(
'Cleared ${_isKvGlobal ? 'global' : 'space'} KV store.',
LogLevel.info);
} else {
await widget.example.db.clear(_selectedTable);
logService.add('Cleared table $_selectedTable.', LogLevel.info);
}
} catch (e, s) {
logService.add('Failed to clear table: $e', LogLevel.error);
logService.add('Stacktrace: $s', LogLevel.error);
}
await _fetchTableData(resetPage: true);
}
}
/// Next sequential index for demo KV keys with [prefix].
///
/// One `orderByKeyDesc` seek: 8-digit suffixes make string max == numeric max.
/// Skips a few non-numeric keys (e.g. leftover `prefix~`) without a count scan.
Future<int> _nextKvBatchStart(String prefix) async {
final page = await widget.example.db.kv
.query(isGlobal: _isKvGlobal)
.prefix(prefix)
.orderByKeyDesc()
.limit(1);
for (final row in page.data) {
final key = row['key']?.toString() ?? '';
if (!key.startsWith(prefix)) continue;
final parsed = int.tryParse(key.substring(prefix.length));
if (parsed != null && parsed > 0) return parsed + 1;
}
return 1;
}
Future<void> _showKvBatchAddDialog() async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (context) => KvBatchAddDialog(
isGlobal: _isKvGlobal,
defaultPrefix: _kKvDefaultKeyPrefix,
),
);
if (!mounted || result == null) return;
final count = result['count'] as int;
final prefix = result['prefix'] as String;
if (count <= 0) return;
setState(() {
_isTesting = true;
_isDataLoading = true;
_lastOperationInfo = 'Adding $count KV pairs...';
});
final sw = Stopwatch()..start();
try {
final startIndex = await _nextKvBatchStart(prefix);
final endIndex = startIndex + count - 1;
const chunkSize = 500;
var written = 0;
for (var start = startIndex; start <= endIndex; start += chunkSize) {
final end = math.min(start + chunkSize - 1, endIndex);
final items = <String, dynamic>{};
for (var i = start; i <= end; i++) {
items[_kvBatchKey(prefix, i)] = 'batch_value_${_kvBatchIndexPart(i)}';
}
final r =
await widget.example.db.kv.setMany(items, isGlobal: _isKvGlobal);
if (r.hasErrors) {
logService.add('KV batch partial error: ${_dbResultErrorMessage(r)}',
LogLevel.warn);
}
written += end - start + 1;
}
sw.stop();
logService.add(
'KV batch add: $written pairs in ${sw.elapsedMilliseconds}ms '
'(${_isKvGlobal ? 'global' : 'space'}, prefix=$prefix, '
'keys ${_kvBatchKey(prefix, startIndex)}..'
'${_kvBatchKey(prefix, endIndex)})',
LogLevel.info);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text('Added $written KV pairs in ${sw.elapsedMilliseconds}ms'),
backgroundColor: Colors.green,
),
);
}
} catch (e, s) {
logService.add('Failed KV batch add: $e', LogLevel.error);
logService.add('Stacktrace: $s', LogLevel.error);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Failed to add KV data. Check logs.'),
backgroundColor: Colors.red,
),
);
}
} finally {
if (mounted) {
setState(() {
_isTesting = false;
});
}
await _fetchTableData(resetPage: true);
}
}
Future<void> _showKvSetDialog(
{String? initialKey, String? initialValue}) async {
final result = await showDialog<Map<String, dynamic>>(
context: context,
builder: (context) => KvSetDialog(
isGlobal: _isKvGlobal,
initialKey: initialKey,
initialValue: initialValue,
),
);
if (!mounted || result == null) return;
final key = (result['key'] as String).trim();
final value = result['value'];
if (key.isEmpty) return;
setState(() {
_isDataLoading = true;
_lastOperationInfo = 'Setting KV key "$key"...';
});
final sw = Stopwatch()..start();
try {
final r =
await widget.example.db.kv.set(key, value, isGlobal: _isKvGlobal);
sw.stop();
if (!r.hasErrors) {
logService.add(
'KV set "$key" = ${_formatKvDisplayValue(value)} '
'(${_isKvGlobal ? 'global' : 'space'}) in ${sw.elapsedMilliseconds}ms',
LogLevel.info);
_showBriefSnackBar('Set "$key" in ${sw.elapsedMilliseconds}ms');
} else {
logService.add(
'KV set failed: ${_dbResultErrorMessage(r)}', LogLevel.error);
}
} catch (e, s) {
logService.add('Failed KV set: $e', LogLevel.error);
logService.add('Stacktrace: $s', LogLevel.error);
}
await _fetchTableData();
}
Future<void> _showKvGetDialog() async {
final keyController = TextEditingController();
final key = await showDialog<String>(
context: context,
builder: (context) => AlertDialog(
title: Text('Get KV (${_isKvGlobal ? 'global' : 'space'})'),
content: TextField(
controller: keyController,
decoration: InputDecoration(
labelText: 'Key',
hintText: _kvBatchKey(_kKvDefaultKeyPrefix, 1),
border: const OutlineInputBorder(),
),
autofocus: true,
onSubmitted: (v) => Navigator.of(context).pop(v.trim()),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () =>
Navigator.of(context).pop(keyController.text.trim()),
child: const Text('Get'),
),
],
),
);
keyController.dispose();
if (!mounted || key == null || key.isEmpty) return;
final sw = Stopwatch()..start();
try {
final value = await widget.example.db.kv.get(key, isGlobal: _isKvGlobal);
sw.stop();
final display = value == null
? '(not found / expired)'
: _formatKvDisplayValue(value);
logService.add(
'KV get "$key" => $display (${_isKvGlobal ? 'global' : 'space'}) '
'in ${sw.elapsedMilliseconds}ms',
LogLevel.info);
_showBriefSnackBar('Get "$key" in ${sw.elapsedMilliseconds}ms');
if (!mounted) return;
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: Text('KV Get: $key (${sw.elapsedMilliseconds}ms)'),
content: SelectableText(display),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Close'),
),
],
),
);
} catch (e, s) {
logService.add('Failed KV get: $e', LogLevel.error);
logService.add('Stacktrace: $s', LogLevel.error);
}
}
Future<void> _copyVisibleLogs() async {
final logs = logService.logs.value;
if (logs.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No logs to copy.'),
duration: Duration(seconds: 2),
),
);
}
return;
}
// Apply the same filtering logic as in the log panel
var filteredLogs = logs;
// Filter by type
if (_selectedLogLevel != null) {
filteredLogs =
filteredLogs.where((log) => log.type == _selectedLogLevel).toList();
}
// Filter by search text
final searchText = _searchController.text.toLowerCase();
if (searchText.isNotEmpty) {
filteredLogs = filteredLogs
.where((log) => log.message.toLowerCase().contains(searchText))
.toList();
}
if (filteredLogs.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No visible logs to copy.'),
duration: Duration(seconds: 2),
),
);
}
return;
}
// Format logs as text (one log per line)
final logText = filteredLogs.map((log) => log.message).join('\n');
// Copy to clipboard
await Clipboard.setData(ClipboardData(text: logText));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Copied ${filteredLogs.length} log(s) to clipboard.'),
duration: const Duration(seconds: 2),
),
);
}
}
Future<void> _copySingleLog(LogEntry logEntry) async {
await Clipboard.setData(ClipboardData(text: logEntry.message));
if (!mounted) return;
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Copied selected log to clipboard.'),
duration: Duration(seconds: 2),
),
);
}
/// Log panel toolbar IconButtons: square hit target + CircleBorder so hover
/// overlay stays circular (M3 default + compact density otherwise flattens it).
Widget _buildLogPanelIconButton({
required IconData icon,
required String tooltip,
required VoidCallback? onPressed,
double? iconSize,
}) {
return IconButton(
style: IconButton.styleFrom(
shape: const CircleBorder(),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(40, 40),
maximumSize: const Size(40, 40),
padding: const EdgeInsets.all(8),
),
icon: Icon(icon, size: iconSize),
tooltip: tooltip,
onPressed: onPressed,
);
}
void _checkAndExpandLogPanel() {
// Threshold is slightly larger than minChildSize to handle tolerances
// and minor user dragging.
if (_logPanelController.isAttached && _logPanelController.size < 0.15) {
// Animate to a size that's large enough to be useful but leaves
// top controls visible. The max is 0.8.
const targetSize = 0.7;
_logPanelController.animateTo(
targetSize,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
}
}
/// A custom SliverPersistentHeaderDelegate for creating a pinned header
/// for the log panel. This ensures the header (with title and action buttons)
/// stays visible while the log content scrolls.
class _LogPanelHeaderDelegate extends SliverPersistentHeaderDelegate {
final Widget child;
final double height;
_LogPanelHeaderDelegate({required this.child, required this.height});
@override
Widget build(
BuildContext context, double shrinkOffset, bool overlapsContent) {
return SizedBox.expand(child: child);
}
@override
double get maxExtent => height;
@override
double get minExtent => height;
@override
bool shouldRebuild(covariant _LogPanelHeaderDelegate oldDelegate) {
return oldDelegate.height != height || oldDelegate.child != child;
}
}
/// Dialog to batch-add demo key-value pairs via KvStore.setMany.
class KvBatchAddDialog extends StatefulWidget {
const KvBatchAddDialog({
super.key,
required this.isGlobal,
this.defaultPrefix = _kKvDefaultKeyPrefix,
});
final bool isGlobal;
final String defaultPrefix;
@override
State<KvBatchAddDialog> createState() => _KvBatchAddDialogState();
}
class _KvBatchAddDialogState extends State<KvBatchAddDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _countController;
late final TextEditingController _prefixController;
@override
void initState() {
super.initState();
_countController = TextEditingController(text: '10000');
_prefixController = TextEditingController(text: widget.defaultPrefix);
}
@override
void dispose() {
_countController.dispose();
_prefixController.dispose();
super.dispose();
}
void _onSubmit() {
if (!_formKey.currentState!.validate()) return;
Navigator.of(context).pop({
'count': int.parse(_countController.text.trim()),
'prefix': _prefixController.text.trim(),
});
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Batch Add KV (${widget.isGlobal ? 'global' : 'space'})'),
content: Form(
key: _formKey,
child: SizedBox(
width: 360,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _countController,
decoration: const InputDecoration(
labelText: 'Count',
border: OutlineInputBorder(),
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
validator: (v) {
final n = int.tryParse(v?.trim() ?? '');
if (n == null || n <= 0) return 'Enter a positive count';
if (n > 100000) return 'Max 100000 per batch';
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _prefixController,
decoration: const InputDecoration(
labelText: 'Key prefix',
hintText: _kKvDefaultKeyPrefix,
border: OutlineInputBorder(),
helperText: 'Continues from max key, e.g. prefix00000001',
),
validator: (v) {
if (v == null || v.trim().isEmpty) {
return 'Prefix is required';
}
return null;
},
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: _onSubmit,
child: const Text('Add'),
),
],
);
}
}
/// Dialog to set a single key-value pair via KvStore.set.
class KvSetDialog extends StatefulWidget {
const KvSetDialog({
super.key,
required this.isGlobal,
this.initialKey,
this.initialValue,
});
final bool isGlobal;
final String? initialKey;
final String? initialValue;
@override
State<KvSetDialog> createState() => _KvSetDialogState();
}
class _KvSetDialogState extends State<KvSetDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _keyController;
late final TextEditingController _valueController;
@override
void initState() {
super.initState();
_keyController = TextEditingController(text: widget.initialKey ?? '');
_valueController = TextEditingController(text: widget.initialValue ?? '');
}
@override
void dispose() {
_keyController.dispose();
_valueController.dispose();
super.dispose();
}
void _onSubmit() {
if (!_formKey.currentState!.validate()) return;
Navigator.of(context).pop({
'key': _keyController.text.trim(),
'value': _parseKvInputValue(_valueController.text),
});
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text('Set KV (${widget.isGlobal ? 'global' : 'space'})'),
content: Form(
key: _formKey,
child: SizedBox(
width: 400,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _keyController,
decoration: const InputDecoration(
labelText: 'Key',
border: OutlineInputBorder(),
),
validator: (v) {
if (v == null || v.trim().isEmpty) return 'Key is required';
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _valueController,
decoration: const InputDecoration(
labelText: 'Value',
border: OutlineInputBorder(),
helperText:
'Plain text, or JSON (e.g. 123, true, {"a":1}, [1,2])',
),
maxLines: 4,
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: _onSubmit,
child: const Text('Set'),
),
],
);
}
}
/// A dialog for configuring and running a custom concurrency test.
class ConcurrencyTestDialog extends StatefulWidget {
const ConcurrencyTestDialog({super.key});
@override
State<ConcurrencyTestDialog> createState() => _ConcurrencyTestDialogState();
}
class _ConcurrencyTestDialogState extends State<ConcurrencyTestDialog> {
final _formKey = GlobalKey<FormState>();
final _controllers = {
ExampleSchemas.users.name: {
'insert': TextEditingController(text: '1000'),
'read': TextEditingController(text: '1000'),
'update': TextEditingController(text: '500'),
'delete': TextEditingController(text: '500'),
},
ExampleSchemas.settings.name: {
'insert': TextEditingController(text: '1000'),
'read': TextEditingController(text: '1000'),
'update': TextEditingController(text: '500'),
'delete': TextEditingController(text: '500'),
},
};
@override
void dispose() {
for (final table in _controllers.values) {
for (final controller in table.values) {
controller.dispose();
}
}
super.dispose();
}
void _onRun() {
if (_formKey.currentState!.validate()) {
final config = _controllers.map((table, operations) {
return MapEntry(table, operations.map((op, controller) {
return MapEntry(op, int.tryParse(controller.text) ?? 0);
}));
});
Navigator.of(context).pop(config);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
// 1. Make dialog wider by reducing horizontal padding
insetPadding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
title: const Text('Configure Concurrency Test'),
// Use a SizedBox to constrain the content width
content: SizedBox(
width: MediaQuery.of(context).size.width, // Use full screen width
child: SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: _controllers.entries.map((tableEntry) {
return _buildTableSection(tableEntry.key, tableEntry.value);
}).toList(),
),
),
),
),
// 2. Center the buttons
actionsAlignment: MainAxisAlignment.center,
actionsPadding: const EdgeInsets.fromLTRB(24, 0, 24, 24),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
const SizedBox(width: 16),
// 3. Style the Run Test button to match the main screen
ElevatedButton(
style: ElevatedButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: const Color.fromARGB(255, 10, 150, 210),
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
),
onPressed: _onRun,
child: const Text('Run Test', style: TextStyle(fontSize: 16)),
),
],
);
}
Widget _buildTableSection(
String title, Map<String, TextEditingController> controllers) {
return Padding(
// 4. Adjust spacing for better visual layout
padding: const EdgeInsets.only(bottom: 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${title[0].toUpperCase()}${title.substring(1)}',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
if (controllers.containsKey('insert'))
_buildOperationRow('Inserts', controllers['insert']!),
if (controllers.containsKey('read'))
_buildOperationRow('Reads', controllers['read']!),
if (controllers.containsKey('update'))
_buildOperationRow('Updates', controllers['update']!),
if (controllers.containsKey('delete'))
_buildOperationRow('Deletes', controllers['delete']!),
],
),
);
}
Widget _buildOperationRow(String label, TextEditingController controller) {
return Padding(
// 4. Adjust spacing
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
flex: 2,
child: Text(label, style: const TextStyle(fontSize: 16)),
),
// 4. Add space between label and field
const SizedBox(width: 24),
Expanded(
flex: 3,
child: TextFormField(
controller: controller,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(
border: OutlineInputBorder(),
isDense: true,
contentPadding:
EdgeInsets.symmetric(horizontal: 10, vertical: 8),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Required';
}
if (int.tryParse(value) == null) {
return 'Invalid';
}
return null;
},
),
),
],
),
);
}
}
enum InsertMethod { batch, oneByOne }
/// A dialog for adding a specific number of records.
class AddDataDialog extends StatefulWidget {
final int defaultCount;
final String tableName;
final ToStore db;
const AddDataDialog({
super.key,
required this.defaultCount,
required this.tableName,
required this.db,
});
@override
State<AddDataDialog> createState() => _AddDataDialogState();
}
class _AddDataDialogState extends State<AddDataDialog> {
late final TextEditingController _controller;
InsertMethod _method = InsertMethod.batch;
bool _isLoading = true;
Map<String, List<Map<String, dynamic>>> _foreignKeyOptions =
{}; // foreign key options for dropdown display (up to 100 records)
final Map<String, dynamic> _selectedForeignKeyValues = {};
Map<String, String> _foreignKeyFieldMap = {}; // fk_field -> referenced_table
final Map<String, ForeignKeyMode> _foreignKeyModes = {}; // fk_field -> mode
final Map<String, List<dynamic>> _foreignKeyIdLists =
{}; // fk_field -> [id1, id2, ...] all IDs for random
final Map<String, int> _foreignKeyTotalCounts =
{}; // fk_field -> total number of records
final Map<String, TextEditingController> _manualInputControllers =
{}; // manual input controller
final Map<String, bool> _useManualInput = {}; // whether to use manual input
List<String> _missingForeignKeyTables =
[]; // records missing data in the main table
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.defaultCount.toString());
_loadForeignKeyData();
}
@override
void dispose() {
_controller.dispose();
for (final controller in _manualInputControllers.values) {
controller.dispose();
}
super.dispose();
}
Future<void> _loadForeignKeyData() async {
try {
final schema = await widget.db.getTableSchema(widget.tableName);
if (schema == null || schema.foreignKeys.isEmpty) {
setState(() {
_isLoading = false;
});
return;
}
final Map<String, List<Map<String, dynamic>>> options = {};
final Map<String, String> fieldMap = {};
final List<String> missingTables = [];
for (final fk in schema.foreignKeys) {
if (!fk.enabled) continue;
// First query the total number of records
final totalCount = await widget.db.query(fk.referencedTable).count();
if (totalCount == 0) {
// The main table has no data, record it in the missing list
missingTables.add(fk.referencedTable);
continue;
}
// Query the first 100 records for dropdown display (performance optimization)
final refTableResult = await widget.db
.query(fk.referencedTable)
.select([fk.referencedFields.first])
.orderByAsc(fk.referencedFields.first)
.limit(100);
// Store option data (up to 100 records for dropdown)
options[fk.fields.first] = refTableResult.data;
fieldMap[fk.fields.first] = fk.referencedTable;
_foreignKeyTotalCounts[fk.fields.first] = totalCount;
// Extract all primary key values for random selection (need to query all data)
// If the total number exceeds 1000, only query the first 1000 records for random (performance consideration)
final maxForRandom = totalCount > 1000 ? 1000 : totalCount;
final allIdsResult = await widget.db
.query(fk.referencedTable)
.select([fk.referencedFields.first])
.orderByAsc(fk.referencedFields.first)
.limit(maxForRandom);
final idList = allIdsResult.data
.map((row) => row[fk.referencedFields.first])
.toList();
_foreignKeyIdLists[fk.fields.first] = idList;
// Initialize manual input controller
_manualInputControllers[fk.fields.first] = TextEditingController();
_useManualInput[fk.fields.first] = false;
// Default select the first record, default mode is random
if (refTableResult.data.isNotEmpty) {
final pkValue = refTableResult.data.first[fk.referencedFields.first];
_selectedForeignKeyValues[fk.fields.first] = pkValue;
_foreignKeyModes[fk.fields.first] =
ForeignKeyMode.random; // Default random mode
}
}
setState(() {
_foreignKeyOptions = options;
_foreignKeyFieldMap = fieldMap;
_missingForeignKeyTables = missingTables;
_isLoading = false;
});
} catch (e) {
logService.add('Error loading foreign key data: $e', LogLevel.error);
setState(() {
_isLoading = false;
});
}
}
void _onAdd() async {
final count = int.tryParse(_controller.text);
if (count != null && count > 0) {
// Check if there are foreign keys but the main table has no data
if (_missingForeignKeyTables.isNotEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'Cannot add: Tables "${_missingForeignKeyTables.join('", "')}" have no data, please add records first.'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 3),
),
);
return;
}
// Process manual input values
final finalForeignKeyValues =
Map<String, dynamic>.from(_selectedForeignKeyValues);
for (final entry in _useManualInput.entries) {
if (entry.value) {
// Use manual input values
final controller = _manualInputControllers[entry.key];
if (controller != null && controller.text.isNotEmpty) {
final inputValue = controller.text.trim();
// Try to convert to number (if the foreign key is a number type)
final numValue = num.tryParse(inputValue);
finalForeignKeyValues[entry.key] = numValue ?? inputValue;
}
}
}
Navigator.of(context).pop({
'count': count,
'method': _method,
'foreignKeyValues': finalForeignKeyValues,
'foreignKeyModes': _foreignKeyModes,
'foreignKeyIdLists': _foreignKeyIdLists,
});
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Add Test Data'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_isLoading)
const Padding(
padding: EdgeInsets.all(16.0),
child: CircularProgressIndicator(),
)
else ...[
// If there are missing main table data, display a warning
if (_missingForeignKeyTables.isNotEmpty)
Container(
padding: const EdgeInsets.all(12.0),
margin: const EdgeInsets.only(bottom: 16.0),
decoration: BoxDecoration(
color: Colors.orange.shade50,
border: Border.all(color: Colors.orange.shade300),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.warning_amber_rounded,
color: Colors.orange.shade700, size: 20),
const SizedBox(width: 8),
Text(
'Missing main table data',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.orange.shade700),
),
],
),
const SizedBox(height: 8),
Text(
'The following tables have no data, please add first:\n${_missingForeignKeyTables.map((t) => '• $t').join('\n')}',
style: TextStyle(color: Colors.orange.shade700),
),
],
),
),
TextField(
controller: _controller,
autofocus: true,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
decoration: const InputDecoration(
labelText: 'Number of records to add',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 20),
// Display foreign key selector
..._foreignKeyOptions.entries.map((entry) {
final fkField = entry.key;
final options = entry.value;
final refTable = _foreignKeyFieldMap[fkField] ?? '';
final selectedValue = _selectedForeignKeyValues[fkField];
final mode = _foreignKeyModes[fkField] ?? ForeignKeyMode.random;
final totalCount = _foreignKeyTotalCounts[fkField] ?? 0;
final displayCount = options.length;
final useManual = _useManualInput[fkField] ?? false;
return Padding(
padding: const EdgeInsets.only(bottom: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
'$fkField (from $refTable)',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
),
Text(
totalCount > displayCount
? '($displayCount/$totalCount records)'
: '($totalCount records)',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
],
),
const SizedBox(height: 8),
// Mode selection
RadioGroup<ForeignKeyMode>(
groupValue: mode,
onChanged: (value) {
setState(() {
_foreignKeyModes[fkField] = value!;
});
},
child: const Row(
children: [
Expanded(
child: RadioListTile<ForeignKeyMode>(
title: Text('Fixed value'),
value: ForeignKeyMode.fixed,
dense: true,
contentPadding: EdgeInsets.zero,
),
),
Expanded(
child: RadioListTile<ForeignKeyMode>(
title: Text('Random value'),
value: ForeignKeyMode.random,
dense: true,
contentPadding: EdgeInsets.zero,
),
),
],
),
),
// Fixed value mode display dropdown or manual input
if (mode == ForeignKeyMode.fixed) ...[
// Select input method
RadioGroup<bool>(
groupValue: useManual,
onChanged: (value) {
setState(() {
_useManualInput[fkField] = value!;
});
},
child: const Row(
children: [
Expanded(
child: RadioListTile<bool>(
title: Text('Dropdown selection'),
value: false,
dense: true,
contentPadding: EdgeInsets.zero,
),
),
Expanded(
child: RadioListTile<bool>(
title: Text('Manual input'),
value: true,
dense: true,
contentPadding: EdgeInsets.zero,
),
),
],
),
),
const SizedBox(height: 8),
if (!useManual)
DropdownButtonFormField<dynamic>(
initialValue: selectedValue,
decoration: InputDecoration(
labelText: 'Select foreign key value',
border: const OutlineInputBorder(),
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
helperText: totalCount > displayCount
? 'Only display the first $displayCount records, total $totalCount records'
: null,
),
items: options.map((option) {
final pkValue = option.values.first;
return DropdownMenuItem<dynamic>(
value: pkValue,
child: Text('$pkValue'),
);
}).toList(),
onChanged: (value) {
setState(() {
_selectedForeignKeyValues[fkField] = value;
});
},
)
else
TextField(
controller: _manualInputControllers[fkField],
decoration: InputDecoration(
labelText: 'Manual input foreign key value',
border: const OutlineInputBorder(),
isDense: true,
contentPadding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 8),
helperText: 'Enter valid $refTable table ID',
hintText: 'For example: 1, 2, 100',
),
keyboardType: TextInputType.number,
onChanged: (value) {
// Real-time update value
if (value.isNotEmpty) {
final numValue = num.tryParse(value);
if (numValue != null) {
_selectedForeignKeyValues[fkField] = numValue;
} else {
_selectedForeignKeyValues[fkField] = value;
}
}
},
),
] else
Container(
padding: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
color: Colors.blue.shade50,
border: Border.all(color: Colors.blue.shade200),
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: [
Icon(Icons.shuffle,
size: 16, color: Colors.blue.shade700),
const SizedBox(width: 8),
Expanded(
child: Text(
totalCount >
(_foreignKeyIdLists[fkField]
?.length ??
0)
? 'Will randomly select from ${_foreignKeyIdLists[fkField]?.length ?? 0} records (total $totalCount records)'
: 'Will randomly select from $totalCount records',
style: TextStyle(
fontSize: 12,
color: Colors.blue.shade700,
),
),
),
],
),
),
],
),
);
}),
const SizedBox(height: 20),
const Text('Insertion Method'),
RadioGroup<InsertMethod>(
groupValue: _method,
onChanged: (value) {
setState(() {
_method = value!;
});
},
child: const Column(
children: [
RadioListTile<InsertMethod>(
title: Text('Batch Insert'),
value: InsertMethod.batch,
),
RadioListTile<InsertMethod>(
title: Text('Insert One by One'),
value: InsertMethod.oneByOne,
),
],
),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: _isLoading ? null : _onAdd,
child: const Text('Add'),
),
],
);
}
}
/// A dialog for editing a single row of data.
class EditRowDialog extends StatefulWidget {
final TableSchema schema;
final Map<String, dynamic> initialData;
const EditRowDialog(
{super.key, required this.schema, required this.initialData});
@override
State<EditRowDialog> createState() => _EditRowDialogState();
}
class _EditRowDialogState extends State<EditRowDialog> {
final _formKey = GlobalKey<FormState>();
late Map<String, TextEditingController> _controllers;
late Map<String, dynamic> _updatedData;
@override
void initState() {
super.initState();
_updatedData = Map.from(widget.initialData);
// Initialize controllers for all fields defined in the schema
_controllers = {
for (var field in widget.schema.fields)
field.name: TextEditingController(
text: '${widget.initialData[field.name] ?? ''}'),
};
// Also add a controller for the primary key, which will be read-only
final pkName = widget.schema.primaryKeyConfig.name;
if (!_controllers.containsKey(pkName) &&
widget.initialData.containsKey(pkName)) {
_controllers[pkName] =
TextEditingController(text: '${widget.initialData[pkName]}');
}
}
@override
void dispose() {
for (var controller in _controllers.values) {
controller.dispose();
}
super.dispose();
}
void _onSave() {
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
Navigator.of(context).pop(_updatedData);
}
}
dynamic _convertValue(String? value, DataType type) {
if (value == null || value.isEmpty || value.toLowerCase() == 'null') {
return null;
}
switch (type) {
case DataType.integer:
return int.tryParse(value);
case DataType.double:
return double.tryParse(value);
case DataType.boolean:
return value.toLowerCase() == 'true' || value == '1';
default:
return value;
}
}
@override
Widget build(BuildContext context) {
final pkName = widget.schema.primaryKeyConfig.name;
return AlertDialog(
title: Text('Edit Record: ${widget.initialData[pkName]}'),
content: Form(
key: _formKey,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Display the primary key as read-only if it exists
if (_controllers.containsKey(pkName))
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: TextFormField(
controller: _controllers[pkName],
readOnly: true,
decoration: InputDecoration(
labelText: '$pkName (Primary Key)',
border: const OutlineInputBorder(),
filled: true,
fillColor: Colors.grey.shade200,
),
),
),
// Editable fields from schema
...widget.schema.fields.map((field) {
// Don't show the primary key again if it's also listed in fields
if (field.name == pkName) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: TextFormField(
controller: _controllers[field.name],
decoration: InputDecoration(
labelText: field.name,
border: const OutlineInputBorder(),
),
validator: (value) {
if (!field.nullable && (value == null || value.isEmpty)) {
return 'This field cannot be empty.';
}
return null;
},
onSaved: (newValue) {
_updatedData[field.name] =
_convertValue(newValue, field.type);
},
),
);
}),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: _onSave,
child: const Text('Save'),
),
],
);
}
}
class BatchUpdateDialog extends StatefulWidget {
final TableSchema schema;
const BatchUpdateDialog({super.key, required this.schema});
@override
State<BatchUpdateDialog> createState() => _BatchUpdateDialogState();
}
class _BatchUpdateDialogState extends State<BatchUpdateDialog> {
final _formKey = GlobalKey<FormState>();
String? _selectedField;
final _valueController = TextEditingController();
late final List<FieldSchema> _updatableFields;
@override
void initState() {
super.initState();
// Exclude primary key and unique fields from batch updates
final pkName = widget.schema.primaryKeyConfig.name;
_updatableFields = widget.schema.fields
.where((f) => f.name != pkName && !f.unique)
.toList();
if (_updatableFields.isNotEmpty) {
_selectedField = _updatableFields.first.name;
}
}
@override
void dispose() {
_valueController.dispose();
super.dispose();
}
void _onSave() {
if (_formKey.currentState!.validate() && _selectedField != null) {
final field =
_updatableFields.firstWhere((f) => f.name == _selectedField);
final value = _convertValue(_valueController.text, field.type);
Navigator.of(context).pop({'field': _selectedField, 'value': value});
}
}
dynamic _convertValue(String? value, DataType type) {
if (value == null || value.isEmpty || value.toLowerCase() == 'null') {
return null;
}
switch (type) {
case DataType.integer:
return int.tryParse(value);
case DataType.double:
return double.tryParse(value);
case DataType.boolean:
return value.toLowerCase() == 'true' || value == '1';
default:
return value;
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Batch Update Selected'),
content: _updatableFields.isEmpty
? const Text(
'No updatable (non-unique) fields available for this table.')
: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
DropdownButtonFormField<String>(
initialValue: _selectedField,
items: _updatableFields.map((field) {
return DropdownMenuItem(
value: field.name,
child: Text(field.name),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedField = value;
});
}
},
decoration: const InputDecoration(
labelText: 'Field to Update',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextFormField(
controller: _valueController,
decoration: const InputDecoration(
labelText: 'New Value',
hintText: 'Enter the new value for all selected rows',
border: OutlineInputBorder(),
),
validator: (value) {
if (_selectedField == null) return null;
final field = _updatableFields
.firstWhere((f) => f.name == _selectedField);
if (!field.nullable && (value == null || value.isEmpty)) {
return 'This field cannot be empty.';
}
return null;
},
)
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: _updatableFields.isEmpty ? null : _onSave,
child: const Text('Update All'),
),
],
);
}
}
class CustomDeleteDialog extends StatefulWidget {
final TableSchema schema;
const CustomDeleteDialog({super.key, required this.schema});
@override
State<CustomDeleteDialog> createState() => _CustomDeleteDialogState();
}
class _CustomDeleteDialogState extends State<CustomDeleteDialog> {
final _formKey = GlobalKey<FormState>();
String? _selectedField;
String _selectedOperator = '>';
final _valueController = TextEditingController();
@override
void initState() {
super.initState();
_selectedField = widget.schema.primaryKeyConfig.name;
}
@override
void dispose() {
_valueController.dispose();
super.dispose();
}
void _onConfirm() {
if (_formKey.currentState!.validate()) {
final fieldSchema = _getSelectedFieldSchema();
if (fieldSchema == null) return;
final convertedValue =
_convertValue(_valueController.text, fieldSchema.type);
Navigator.of(context).pop({
'field': _selectedField,
'operator': _selectedOperator,
'value': convertedValue,
});
}
}
FieldSchema? _getSelectedFieldSchema() {
if (_selectedField == widget.schema.primaryKeyConfig.name) {
return FieldSchema(
name: _selectedField!,
type: widget.schema.primaryKeyConfig.getDefaultDataType(),
);
}
return widget.schema.fields.firstWhere((f) => f.name == _selectedField);
}
dynamic _convertValue(String? value, DataType type) {
if (value == null || value.isEmpty || value.toLowerCase() == 'null') {
return null;
}
switch (type) {
case DataType.integer:
return int.tryParse(value);
case DataType.double:
return double.tryParse(value);
case DataType.boolean:
return value.toLowerCase() == 'true' || value == '1';
default:
return value;
}
}
@override
Widget build(BuildContext context) {
final allFields = [
widget.schema.primaryKeyConfig.name,
...widget.schema.fields.map((f) => f.name)
];
final selectedFieldSchema = _getSelectedFieldSchema();
final isNumeric = selectedFieldSchema?.type == DataType.integer ||
selectedFieldSchema?.type == DataType.double;
return AlertDialog(
title: const Text('Custom Delete'),
content: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Delete records based on the following condition:'),
const SizedBox(height: 24),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 4,
child: DropdownButtonFormField<String>(
initialValue: _selectedField,
items: allFields
.map((field) =>
DropdownMenuItem(value: field, child: Text(field)))
.toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedField = value;
// Reset value when field changes
_valueController.clear();
});
}
},
decoration: const InputDecoration(
labelText: 'Field',
border: OutlineInputBorder(),
),
),
),
const SizedBox(width: 8),
Expanded(
flex: 3,
child: DropdownButtonFormField<String>(
initialValue: _selectedOperator,
items: ['>', '>=', '<', '<=', '=', '!=', 'LIKE']
.map((op) =>
DropdownMenuItem(value: op, child: Text(op)))
.toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedOperator = value;
});
}
},
decoration: const InputDecoration(
border: OutlineInputBorder(),
),
),
),
],
),
const SizedBox(height: 16),
TextFormField(
controller: _valueController,
autofocus: true,
keyboardType:
isNumeric ? TextInputType.number : TextInputType.text,
inputFormatters:
isNumeric ? [FilteringTextInputFormatter.digitsOnly] : [],
decoration: const InputDecoration(
labelText: 'Value',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Required';
}
if (isNumeric && int.tryParse(value) == null) {
return 'Invalid number';
}
// Add more validation for other types if needed
return null;
},
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: _onConfirm,
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: const Text('Delete', style: TextStyle(color: Colors.white)),
),
],
);
}
}
class FilterDialog extends StatefulWidget {
final TableSchema schema;
final List<Map<String, dynamic>> existingFilters;
const FilterDialog(
{super.key, required this.schema, required this.existingFilters});
@override
State<FilterDialog> createState() => _FilterDialogState();
}
class _FilterDialogState extends State<FilterDialog> {
late List<_FilterCondition> _filters;
final _formKey = GlobalKey<FormState>();
@override
void initState() {
super.initState();
_filters = widget.existingFilters.map((f) {
return _FilterCondition(
field: f['field'],
operator: f['operator'],
valueController: TextEditingController(text: '${f['value']}'),
);
}).toList();
if (_filters.isEmpty) {
_filters.add(
_FilterCondition(
field: _getAvailableFields().first,
operator: '=',
valueController: TextEditingController(),
),
);
}
}
@override
void dispose() {
for (final filter in _filters) {
filter.valueController.dispose();
}
super.dispose();
}
void _addFilter() {
setState(() {
_filters.add(_FilterCondition(
field: _getAvailableFields().first,
operator: '=',
valueController: TextEditingController(),
));
});
}
void _removeFilter(int index) {
setState(() {
_filters.removeAt(index);
});
}
List<String> _getAvailableFields() {
final pkName = widget.schema.primaryKeyConfig.name;
final fieldNames = widget.schema.fields.map((f) => f.name).toSet();
fieldNames.add(pkName);
return fieldNames.toList()..sort();
}
void _onApply() {
if (_formKey.currentState!.validate()) {
final newFilters = _filters.map((f) {
final fieldSchema = _getFieldSchema(f.field);
final raw = f.valueController.text;
// LIKE / startsWith patterns must stay strings (e.g. "123%", "user_2@...").
// Never int.tryParse them — that turns "123%" into null for integer PKs.
final dynamic value;
if (f.operator == 'LIKE' ||
f.operator == 'startsWith' ||
f.operator == 'prefix') {
value = raw;
} else {
value = _convertValue(raw, fieldSchema?.type ?? DataType.text);
}
return {
'field': f.field,
'operator': f.operator,
'value': value,
};
}).toList();
Navigator.of(context).pop(newFilters);
}
}
FieldSchema? _getFieldSchema(String fieldName) {
if (fieldName == widget.schema.primaryKeyConfig.name) {
// PK storage type is text for all PrimaryKeyType variants in the engine.
return FieldSchema(
name: fieldName,
type: widget.schema.primaryKeyConfig.getDefaultDataType(),
);
}
try {
return widget.schema.fields.firstWhere((f) => f.name == fieldName);
} catch (_) {
return null;
}
}
dynamic _convertValue(String? value, DataType type) {
if (value == null || value.isEmpty || value.toLowerCase() == 'null') {
return null;
}
switch (type) {
case DataType.integer:
return int.tryParse(value);
case DataType.double:
return double.tryParse(value);
case DataType.boolean:
return value.toLowerCase() == 'true' || value == '1';
default:
return value;
}
}
@override
Widget build(BuildContext context) {
final availableFields = _getAvailableFields();
return AlertDialog(
title: const Text('Set Filter Conditions'),
content: Form(
key: _formKey,
child: SizedBox(
width: double.maxFinite,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: _filters.isEmpty
? const Center(
child: Text('No filters. Add one below.'),
)
: ListView.builder(
shrinkWrap: true,
itemCount: _filters.length,
itemBuilder: (context, index) {
final filter = _filters[index];
return _buildFilterRow(
filter, index, availableFields);
},
),
),
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: _addFilter,
icon: const Icon(Icons.add),
label: const Text('Add Condition'),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: _onApply,
child: const Text('Apply Filters'),
),
],
);
}
Widget _buildFilterRow(
_FilterCondition filter, int index, List<String> availableFields) {
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 8.0,
),
child: Column(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 5,
child: DropdownButtonFormField<String>(
initialValue: filter.field,
items: availableFields
.map((f) =>
DropdownMenuItem(value: f, child: Text(f)))
.toList(),
onChanged: (value) {
if (value != null) {
setState(() {
filter.field = value;
});
}
},
decoration: const InputDecoration(
border: OutlineInputBorder(), labelText: 'Field'),
),
),
const SizedBox(width: 8),
Expanded(
flex: 3,
child: DropdownButtonFormField<String>(
initialValue: filter.operator,
items: [
'=',
'!=',
'>',
'>=',
'<',
'<=',
'LIKE',
'startsWith',
]
.map((op) =>
DropdownMenuItem(value: op, child: Text(op)))
.toList(),
onChanged: (value) {
if (value != null) {
setState(() {
filter.operator = value;
});
}
},
decoration:
const InputDecoration(border: OutlineInputBorder()),
),
),
],
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
flex: 5,
child: TextFormField(
controller: filter.valueController,
decoration: InputDecoration(
border: const OutlineInputBorder(),
labelText: 'Value',
helperText: filter.operator == 'startsWith'
? 'Literal prefix (no % needed; _ is literal)'
: filter.operator == 'LIKE'
? r'SQL LIKE: escape _/% as \_ \%'
: null,
helperMaxLines: 2,
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Required';
}
return null;
},
),
),
IconButton(
icon:
const Icon(Icons.remove_circle_outline, color: Colors.red),
onPressed: () => _removeFilter(index),
)
],
),
],
),
);
}
}
class _FilterCondition {
String field;
String operator;
TextEditingController valueController;
_FilterCondition({
required this.field,
required this.operator,
required this.valueController,
});
}
class VectorSearchDialog extends StatefulWidget {
const VectorSearchDialog({super.key});
@override
State<VectorSearchDialog> createState() => _VectorSearchDialogState();
}
class _VectorSearchDialogState extends State<VectorSearchDialog> {
// Presets aligned to recall-intent mapping (depth → ~90–100%).
static const int _depthFast = 30; // ~93%
static const int _depthBalanced = 50; // ~95% production baseline
static const int _depthDeep = 80; // ~98%
int _iterations = 1;
int _topK = 10;
int _searchDepth = _depthBalanced;
final _customController = TextEditingController(text: '1');
final _depthController = TextEditingController(text: '$_depthBalanced');
@override
void dispose() {
_customController.dispose();
_depthController.dispose();
super.dispose();
}
void _setSearchDepth(int depth) {
final clamped = depth.clamp(1, 100);
setState(() {
_searchDepth = clamped;
_depthController.text = clamped.toString();
});
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Row(
children: [
Icon(Icons.query_stats, color: Color(0xff0aa6e8)),
SizedBox(width: 10),
Expanded(
child: Text(
'Vector Search Benchmark',
overflow: TextOverflow.ellipsis,
),
),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Search Config',
style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
const Text('Top-K (Number of neighbors):'),
Slider(
value: _topK.toDouble(),
min: 1,
max: 100,
divisions: 99,
label: _topK.toString(),
activeColor: const Color(0xff0aa6e8),
onChanged: (v) => setState(() => _topK = v.toInt()),
),
Center(child: Text('$_topK results per search')),
const SizedBox(height: 24),
const Text('Search Depth (1–100):',
style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
(_depthFast, 'Fast'),
(_depthBalanced, 'Balanced'),
(_depthDeep, 'Deep'),
].map((preset) {
final depth = preset.$1;
final label = preset.$2;
final isSelected = _searchDepth == depth;
return ChoiceChip(
label: Text('$label ($depth)'),
selected: isSelected,
selectedColor: const Color(0xff0aa6e8),
checkmarkColor: Colors.white,
labelStyle: TextStyle(
color: isSelected ? Colors.white : Colors.black,
),
onSelected: (selected) {
if (selected) _setSearchDepth(depth);
},
);
}).toList(),
),
const SizedBox(height: 12),
TextField(
controller: _depthController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Custom Search Depth',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.layers),
helperText:
'Higher ≈ better recall intent + more latency (not recall%)',
),
onChanged: (v) {
final val = int.tryParse(v);
if (val != null && val >= 1 && val <= 100) {
setState(() => _searchDepth = val);
}
},
),
const SizedBox(height: 24),
const Text('Iterations:',
style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [1, 100, 1000, 10000].map((count) {
final isSelected = _iterations == count;
return ChoiceChip(
label: Text(count == 1 ? 'Single' : count.toString()),
selected: isSelected,
selectedColor: const Color(0xff0aa6e8),
checkmarkColor: Colors.white,
labelStyle: TextStyle(
color: isSelected ? Colors.white : Colors.black,
),
onSelected: (selected) {
if (selected) {
setState(() {
_iterations = count;
_customController.text = count.toString();
});
}
},
);
}).toList(),
),
const SizedBox(height: 16),
TextField(
controller: _customController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Custom Iterations',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.repeat),
),
onChanged: (v) {
final val = int.tryParse(v);
if (val != null && val > 0) {
setState(() => _iterations = val);
}
},
),
const SizedBox(height: 16),
const Text(
'Total searches to perform. Results will be averaged to measure latency.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
final parsedDepth = int.tryParse(_depthController.text.trim());
final depth = (parsedDepth ?? _searchDepth).clamp(1, 100);
Navigator.of(context).pop({
'iterations': _iterations,
'topK': _topK,
'searchDepth': depth,
});
},
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xff0aa6e8),
foregroundColor: Colors.white,
),
child: const Text('Start Benchmark'),
),
],
);
}
}
/// Pseudo table labels for the system key-value store (not real table names).
/// Listing uses [KvStore.getKeys]/[KvStore.get] because system tables cannot
/// be queried via [ToStore.query].
const String _kKvSpaceLabel = 'KV (space)';
const String _kKvGlobalLabel = 'KV (global)';
const String _kKvDefaultKeyPrefix = 'demo_kv_';
/// Fixed-width numeric suffix so key DESC order matches numeric max.
const int _kKvBatchIndexWidth = 8;
String _kvBatchIndexPart(int index) =>
index.toString().padLeft(_kKvBatchIndexWidth, '0');
String _kvBatchKey(String prefix, int index) =>
'$prefix${_kvBatchIndexPart(index)}';
dynamic _parseKvInputValue(String raw) {
final trimmed = raw.trim();
if (trimmed.isEmpty) return '';
try {
return jsonDecode(trimmed);
} catch (_) {
return raw;
}
}
String _formatKvDisplayValue(dynamic value) {
if (value == null) return 'null';
if (value is String) return value;
try {
return jsonEncode(value);
} catch (_) {
return value.toString();
}
}