strategy_forge 0.1.1
strategy_forge: ^0.1.1 copied to clipboard
A customizable Flutter strategy board for different sports and use cases, with configurable layouts, tools, JSON persistence, and PNG export.
import 'package:strategy_forge/strategy_forge.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:file_saver/file_saver.dart';
import 'demo_configurations.dart';
void main() => runApp(const StrategyForgeDemo());
class StrategyForgeDemo extends StatelessWidget {
const StrategyForgeDemo({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
title: 'Strategy Forge',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xff0b5b8c),
surface: const Color(0xfffbfcfe),
),
scaffoldBackgroundColor: const Color(0xffeef3f7),
useMaterial3: true,
appBarTheme: const AppBarTheme(
backgroundColor: Color(0xff0d2a45),
foregroundColor: Colors.white,
elevation: 0,
scrolledUnderElevation: 0,
toolbarHeight: 68,
titleSpacing: 16,
surfaceTintColor: Colors.transparent,
),
popupMenuTheme: PopupMenuThemeData(
elevation: 8,
color: const Color(0xffffffff),
surfaceTintColor: Colors.transparent,
menuPadding: const EdgeInsets.symmetric(vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: const BorderSide(color: Color(0xffd9e2e9)),
),
),
dialogTheme: DialogThemeData(
backgroundColor: const Color(0xffffffff),
surfaceTintColor: Colors.transparent,
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
),
tooltipTheme: TooltipThemeData(
waitDuration: const Duration(milliseconds: 450),
decoration: BoxDecoration(
color: const Color(0xff17364f),
borderRadius: BorderRadius.circular(8),
),
textStyle: const TextStyle(color: Colors.white, fontSize: 12),
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
insetPadding: const EdgeInsets.all(16),
),
iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom(
minimumSize: const Size.square(48),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
home: const DemoHome(),
);
}
class DemoHome extends StatefulWidget {
const DemoHome({
super.key,
this.initialDownloadEnabled = true,
this.strategyJson,
this.onStrategyJsonChanged,
this.onStrategyImageGenerated,
this.savePng,
});
final bool initialDownloadEnabled;
/// Saved plugin data to restore. A later changed value is loaded as well.
final String? strategyJson;
/// Receives the current plugin document as JSON whenever it changes.
final ValueChanged<String>? onStrategyJsonChanged;
/// Receives the PNG bytes generated by the plugin before file download.
final ValueChanged<Uint8List>? onStrategyImageGenerated;
final Future<bool> Function(Uint8List bytes)? savePng;
@override
State<DemoHome> createState() => _DemoHomeState();
}
class _DemoHomeState extends State<DemoHome> {
late final StrategyEditorController _controller;
late bool _downloadEnabled;
bool _saving = false;
String? _lastEmittedJson;
@override
void initState() {
super.initState();
_controller = StrategyEditorController(config: DemoConfigurations.sample());
if (widget.strategyJson case final json?) {
_controller.loadJson(json);
}
_controller.addListener(_emitJsonIfChanged);
_downloadEnabled = widget.initialDownloadEnabled;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _emitJsonIfChanged();
});
}
@override
void didUpdateWidget(covariant DemoHome oldWidget) {
super.didUpdateWidget(oldWidget);
final json = widget.strategyJson;
if (json != null && json != oldWidget.strategyJson) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && json != _controller.exportJson()) {
_controller.loadJson(json);
}
});
}
if (widget.onStrategyJsonChanged != oldWidget.onStrategyJsonChanged) {
_lastEmittedJson = null;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _emitJsonIfChanged();
});
}
}
@override
void dispose() {
_controller.removeListener(_emitJsonIfChanged);
_controller.dispose();
super.dispose();
}
void _emitJsonIfChanged() {
final json = _controller.exportJson();
if (json == _lastEmittedJson) return;
_lastEmittedJson = json;
widget.onStrategyJsonChanged?.call(json);
}
Future<void> _export() async {
if (!_downloadEnabled || _saving) return;
setState(() => _saving = true);
final messenger = ScaffoldMessenger.of(context);
try {
final bytes = await _controller.exportPng();
if (!mounted || !_downloadEnabled) return;
widget.onStrategyImageGenerated?.call(bytes);
final saved = await (widget.savePng?.call(bytes) ?? _savePng(bytes));
if (!mounted) return;
messenger.showSnackBar(
SnackBar(
content: Text(saved ? 'PNG saved' : 'PNG save cancelled or failed'),
),
);
} catch (error) {
if (!mounted) return;
messenger.showSnackBar(
SnackBar(content: Text('Could not save PNG: $error')),
);
} finally {
if (mounted) setState(() => _saving = false);
}
}
Future<bool> _savePng(Uint8List bytes) async {
final fileName = 'strategy_${DateTime.now().millisecondsSinceEpoch}';
final result = kIsWeb
? await FileSaver.instance.saveFile(
name: fileName,
bytes: bytes,
fileExtension: 'png',
mimeType: MimeType.png,
)
: await FileSaver.instance.saveAs(
name: fileName,
bytes: bytes,
fileExtension: 'png',
mimeType: MimeType.png,
);
return result != null && result.isNotEmpty;
}
Widget _buildTitle(BuildContext context, {required bool showSubtitle}) {
return Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: .12),
borderRadius: BorderRadius.circular(11),
border: Border.all(color: Colors.white24),
),
child: const Icon(Icons.dashboard_customize_outlined, size: 22),
),
const SizedBox(width: 11),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Strategy Forge',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.w700,
letterSpacing: -0.2,
),
),
if (showSubtitle)
Text(
'Strategy editor demo',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.white70,
letterSpacing: 0.4,
),
),
],
),
),
],
);
}
ButtonStyle _downloadButtonStyle() => IconButton.styleFrom(
backgroundColor: Colors.white.withValues(alpha: .12),
disabledBackgroundColor: Colors.white.withValues(alpha: .05),
foregroundColor: Colors.white,
disabledForegroundColor: Colors.white38,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: const BorderSide(color: Colors.white24),
),
);
@override
Widget build(BuildContext context) => LayoutBuilder(
builder: (context, constraints) {
final shortViewport = constraints.maxHeight < 600;
return Scaffold(
appBar: AppBar(
toolbarHeight: shortViewport ? 56 : 68,
title: _buildTitle(
context,
showSubtitle: constraints.maxWidth >= 520 && !shortViewport,
),
actions: [
IconButton(
key: const ValueKey('demo-download'),
tooltip: _downloadEnabled
? 'Download strategy as PNG'
: 'PNG downloads are disabled',
onPressed: _downloadEnabled && !_saving ? _export : null,
constraints:
const BoxConstraints.tightFor(width: 48, height: 48),
style: _downloadButtonStyle(),
icon: _saving
? const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.download_rounded),
),
const SizedBox(width: 4),
],
),
body: SafeArea(child: StrategyEditor(controller: _controller)),
);
},
);
}