fl_nodes 0.5.0+1 copy "fl_nodes: ^0.5.0+1" to clipboard
fl_nodes: ^0.5.0+1 copied to clipboard

A lightweight, scalable, and highly customizable package that empowers Flutter developers to create dynamic, interactive, and visually appealing node-based UIs.

example/lib/main.dart

import 'dart:convert';
import 'dart:io';

import 'package:example/models/locale.dart';
import 'package:example/nodes/data/handlers.dart';
import 'package:example/nodes/prototypes/prototypes.dart';
import 'package:example/utils/snackbar.dart';
import 'package:example/widgets/hierarchy.dart';
import 'package:example/widgets/instructions.dart';
import 'package:example/widgets/settings.dart';
import 'package:example/widgets/terminal.dart';
import 'package:file_picker/file_picker.dart';
import 'package:fl_nodes/fl_nodes.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart';

import 'l10n/app_localizations.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const NodeEditorExampleApp());
}

class NodeEditorExampleApp extends StatefulWidget {
  const NodeEditorExampleApp({super.key});

  @override
  State<NodeEditorExampleApp> createState() => _NodeEditorExampleAppState();
}

class _NodeEditorExampleAppState extends State<NodeEditorExampleApp> {
  late Locale _locale;

  final List<LocaleDataModel> locales = [
    const LocaleDataModel('en', '🇺🇸', 'English'),
    const LocaleDataModel('it', '🇮🇹', 'Italiano'),
    const LocaleDataModel('fr', '🇫🇷', 'Français'),
    const LocaleDataModel('es', '🇪🇸', 'Español'),
    const LocaleDataModel('de', '🇩🇪', 'Deutsch'),
    const LocaleDataModel('ja', '🇯🇵', '日本語'),
    const LocaleDataModel('zh', '🇨🇳', '中文'),
    const LocaleDataModel('ko', '🇰🇷', '한국어'),
    const LocaleDataModel('ru', '🇷🇺', 'Русский'),
    const LocaleDataModel('ar', '🇸🇦', 'العربية'),
  ];

  void _setLocale(String languageCode) {
    setState(() {
      _locale = Locale(languageCode);
    });
  }

  @override
  void initState() {
    super.initState();

    final systemLocale = WidgetsBinding.instance.platformDispatcher.locale;
    final supportedLanguageCodes = locales.map((l) => l.code).toSet();
    final defaultLanguageCode =
        supportedLanguageCodes.contains(systemLocale.languageCode)
            ? systemLocale.languageCode
            : 'en';

    _locale = Locale(defaultLanguageCode);
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      localizationsDelegates: [
        AppLocalizations.delegate,
        const FlNodeEditorLocalizationsDelegate(),
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
        GlobalCupertinoLocalizations.delegate,
      ],
      supportedLocales: locales.map((l) => Locale(l.code)).toList(),
      locale: _locale,
      title: 'Fl Nodes Example',
      theme: ThemeData.dark().copyWith(
        colorScheme: ColorScheme.fromSeed(
          seedColor: Colors.blue,
          brightness: Brightness.dark,
        ),
        appBarTheme: const AppBarTheme(
          backgroundColor: Colors.transparent,
          elevation: 0,
        ),
      ),
      home: NodeEditorExampleScreen(
        locales: locales,
        currentLocale: _locale,
        onLocaleChanged: _setLocale,
      ),
      debugShowCheckedModeBanner: kDebugMode,
    );
  }
}

class NodeEditorExampleScreen extends StatefulWidget {
  const NodeEditorExampleScreen({
    super.key,
    required this.locales,
    required this.currentLocale,
    required this.onLocaleChanged,
  });

  final List<LocaleDataModel> locales;
  final Locale currentLocale;
  final Function(String) onLocaleChanged;

  @override
  State<NodeEditorExampleScreen> createState() =>
      NodeEditorExampleScreenState();
}

final bool isMobile = TargetPlatform.iOS == defaultTargetPlatform ||
        TargetPlatform.android == defaultTargetPlatform
    ? true
    : false;

class NodeEditorExampleScreenState extends State<NodeEditorExampleScreen> {
  late final FlNodeEditorController _nodeEditorController;
  final TerminalController _terminalController = TerminalController();

  bool isHierarchyCollapsed = isMobile ? true : false;
  bool isHierarchyFullyCollapsed = isMobile ? true : false;
  bool isTerminalCollapsed = isMobile ? true : false;
  bool isTerminalFullyCollapsed = isMobile ? true : false;

  void _toggleHierarchy() {
    setState(() {
      isHierarchyCollapsed = !isHierarchyCollapsed;
    });
  }

  void _toggleTerminal() {
    setState(() {
      isTerminalCollapsed = !isTerminalCollapsed;
    });
  }

  @override
  void initState() {
    super.initState();

    _nodeEditorController = FlNodeEditorController(
      projectSaver: (jsonData) async {
        final String? outputPath = await FilePicker.platform.saveFile(
          dialogTitle: AppLocalizations.of(context)!.saveProjectDialogTitle,
          fileName: 'node_project.json',
          type: FileType.custom,
          allowedExtensions: ['json'],
          bytes: utf8.encode(jsonEncode(jsonData)),
        );

        if (outputPath != null || kIsWeb) {
          return true;
        } else {
          return false;
        }
      },
      projectLoader: (isSaved) async {
        if (!isSaved) {
          final bool? proceed = await _showUnsavedChangesDialog();
          if (proceed != true) return null;
        }

        final FilePickerResult? result = await FilePicker.platform.pickFiles(
          type: FileType.custom,
          allowedExtensions: ['json'],
        );

        if (result == null) return null;

        late final String fileContent;

        if (kIsWeb) {
          final byteData = result.files.single.bytes!;
          fileContent = utf8.decode(byteData.buffer.asUint8List());
        } else {
          final File file = File(result.files.single.path!);
          fileContent = await file.readAsString();
        }

        return jsonDecode(fileContent);
      },
      projectCreator: (isSaved) async {
        if (isSaved) return true;
        return await _showUnsavedChangesDialog() == true;
      },
      onCallback: (type, message) =>
          showNodeEditorSnackbar(context, message, type),
    );

    registerDataHandlers(_nodeEditorController);
    registerNodes(context, _nodeEditorController);

    _loadSampleProject();
  }

  Future<bool?> _showUnsavedChangesDialog() {
    return showDialog<bool>(
      context: context,
      builder: (BuildContext context) {
        return AlertDialog(
          title: Text(AppLocalizations.of(context)!.unsavedChangesTitle),
          content: Text(AppLocalizations.of(context)!.unsavedChangesMsg),
          actions: <Widget>[
            TextButton(
              onPressed: () => Navigator.of(context).pop(false),
              child: Text(AppLocalizations.of(context)!.cancel),
            ),
            TextButton(
              onPressed: () => Navigator.of(context).pop(true),
              child: Text(AppLocalizations.of(context)!.proceed),
            ),
          ],
        );
      },
    );
  }

  Future<void> _loadSampleProject() async {
    const sampleProjectLink =
        'https://raw.githubusercontent.com/WilliamKarolDiCioccio/fl_nodes/refs/heads/main/example/assets/www/node_project.json';

    try {
      final response = await http.get(Uri.parse(sampleProjectLink));

      if (response.statusCode == 200 && mounted) {
        _nodeEditorController.project.load(
          data: jsonDecode(response.body),
          context: context,
        );
      } else {
        if (mounted) {
          _showErrorSnackbar(
            AppLocalizations.of(context)!.failedToLoadSampleProject,
          );
        }
      }
    } catch (e) {
      if (mounted) {
        _showErrorSnackbar(
          AppLocalizations.of(context)!.failedToLoadSampleProject,
        );
      }
    }
  }

  void _showErrorSnackbar(String message) {
    if (!mounted) return;
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(message),
        backgroundColor: Theme.of(context).colorScheme.error,
      ),
    );
  }

  void _showSettingsPanel() {
    showModalBottomSheet(
      context: context,
      isScrollControlled: true,
      backgroundColor: Colors.transparent,
      builder: (context) => SettingsPanel(
        locales: widget.locales,
        currentLocale: widget.currentLocale,
        onLocaleChanged: widget.onLocaleChanged,
        controller: _nodeEditorController,
      ),
    );
  }

  void _showInstructionsPanel() {
    showModalBottomSheet(
      context: context,
      isScrollControlled: true,
      backgroundColor: Colors.transparent,
      builder: (context) => const InstructionsPanel(),
    );
  }

  Future<void> _launchGitHub() async {
    const url = 'https://github.com/WilliamKarolDiCioccio/fl_nodes';
    final uri = Uri.parse(url);

    if (await canLaunchUrl(uri)) {
      await launchUrl(uri, mode: LaunchMode.externalApplication);
    } else {
      _showErrorSnackbar('Could not launch GitHub');
    }
  }

  @override
  void dispose() {
    _nodeEditorController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: FlNodeEditorShortcutsWidget(
        controller: _nodeEditorController,
        child: Row(
          children: [
            ClipRect(
              child: TweenAnimationBuilder<double>(
                tween: Tween<double>(
                  begin: isHierarchyCollapsed ? 1.0 : 0.0,
                  end: isHierarchyCollapsed ? 0.0 : 1.0,
                ),
                duration: const Duration(milliseconds: 300),
                curve: Curves.easeInOut,
                onEnd: () {
                  setState(() {
                    isHierarchyFullyCollapsed = isHierarchyCollapsed;
                  });
                },
                builder: (context, widthFactor, child) {
                  return Align(
                    alignment: Alignment.centerLeft,
                    widthFactor: widthFactor.clamp(0.0, 1.0),
                    child: child,
                  );
                },
                child: SizedBox(
                  width: 300,
                  child: HierarchyWidget(
                    controller: _nodeEditorController,
                    isCollapsed: isHierarchyFullyCollapsed,
                  ),
                ),
              ),
            ),
            Expanded(
              child: Column(
                children: [
                  Expanded(
                    child: FlNodeEditorWidget(
                      controller: _nodeEditorController,
                      expandToParent: true,
                      overlay: () => [
                        FlOverlayData(
                          child: _buildTopToolbar(),
                        ),
                      ],
                    ),
                  ),
                  ClipRect(
                    child: TweenAnimationBuilder<double>(
                      tween: Tween<double>(
                        begin: isTerminalCollapsed ? 1.0 : 0.0,
                        end: isTerminalCollapsed ? 0.0 : 1.0,
                      ),
                      duration: const Duration(milliseconds: 300),
                      curve: Curves.easeInOut,
                      onEnd: () {
                        setState(() {
                          isTerminalFullyCollapsed = isTerminalCollapsed;
                        });
                      },
                      builder: (context, heightFactor, child) {
                        return Align(
                          alignment: Alignment.bottomCenter,
                          heightFactor: heightFactor.clamp(0.0, 1.0),
                          child: child,
                        );
                      },
                      child: SizedBox(
                        height: 400,
                        child: TerminalWidget(
                          controller: _terminalController,
                          isCollapsed: isTerminalFullyCollapsed,
                        ),
                      ),
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildTopToolbar() {
    final strings = AppLocalizations.of(context)!;

    return Padding(
      padding: const EdgeInsets.all(16),
      child: Row(
        spacing: 16,
        children: [
          // Hierarchy controls
          _buildToobarSection(
            children: [
              _buildToolbarButton(
                icon: isHierarchyCollapsed ? Icons.menu_open : Icons.menu,
                tooltip: AppLocalizations.of(context)!.toggleHierarchyTooltip,
                onPressed: _toggleHierarchy,
              ),
              _buildToolbarButton(
                icon: isTerminalCollapsed
                    ? Icons.terminal
                    : Icons.terminal_outlined,
                tooltip: AppLocalizations.of(context)!.toggleTerminalTooltip,
                onPressed: _toggleTerminal,
              ),
            ],
          ),
          // Editor controls
          _buildToobarSection(
            children: [
              _buildToolbarButton(
                icon: Icons.add,
                tooltip: strings.createProjectActionTooltip,
                onPressed: () =>
                    _nodeEditorController.project.create(context: context),
              ),
              _buildToolbarButton(
                icon: Icons.folder_open,
                tooltip: strings.openProjectActionTooltip,
                onPressed: () =>
                    _nodeEditorController.project.load(context: context),
              ),
              _buildToolbarButton(
                icon: Icons.save,
                tooltip: strings.saveProjectActionTooltip,
                onPressed: () =>
                    _nodeEditorController.project.save(context: context),
              ),
              _buildToolbarButton(
                icon: Icons.undo,
                tooltip: strings.undoActionTooltip,
                onPressed: () => _nodeEditorController.history.undo(),
              ),
              _buildToolbarButton(
                icon: Icons.redo,
                tooltip: strings.redoActionTooltip,
                onPressed: () => _nodeEditorController.history.redo(),
              ),
              _buildToolbarButton(
                icon: _nodeEditorController.config.enableSnapToGrid
                    ? Icons.grid_on
                    : Icons.grid_off,
                tooltip: AppLocalizations.of(context)!.toggleSnapToGridTooltip,
                onPressed: () => setState(() {
                  _nodeEditorController.enableSnapToGrid(
                    !_nodeEditorController.config.enableSnapToGrid,
                  );
                }),
              ),
              _buildToolbarButton(
                icon: Icons.play_arrow,
                tooltip: AppLocalizations.of(context)!.executeGraphTooltip,
                onPressed: () =>
                    _nodeEditorController.runner.executeGraph(context: context),
                color: Colors.green,
              ),
            ],
          ),
          const Spacer(),
          // Miscellaneous
          _buildToobarSection(
            children: [
              _buildToolbarButton(
                icon: Icons.star_border,
                tooltip: strings.starOnGitHubTooltip,
                onPressed: _launchGitHub,
                color: Colors.amber,
              ),
              _buildToolbarButton(
                icon: Icons.settings,
                tooltip: strings.settingsTooltip,
                onPressed: _showSettingsPanel,
              ),
              _buildToolbarButton(
                icon: Icons.help_outline,
                tooltip: strings.instructionsTooltip,
                onPressed: _showInstructionsPanel,
              ),
            ],
          ),
        ],
      ),
    );
  }

  Widget _buildToobarSection({required List<Widget> children}) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
      decoration: BoxDecoration(
        color: Theme.of(context).colorScheme.surface.withAlpha(230),
        borderRadius: BorderRadius.circular(12),
        border: Border.all(
          color: Theme.of(context).colorScheme.outline.withAlpha(51),
        ),
        boxShadow: [
          BoxShadow(
            color: Colors.black.withAlpha(25),
            blurRadius: 8,
            offset: const Offset(0, 2),
          ),
        ],
      ),
      child: Row(
        mainAxisSize: MainAxisSize.min,
        spacing: 8,
        children: children,
      ),
    );
  }

  Widget _buildToolbarButton({
    required IconData icon,
    required String tooltip,
    required VoidCallback onPressed,
    Color? color,
  }) {
    return Tooltip(
      message: tooltip,
      child: Material(
        color: Colors.transparent,
        child: InkWell(
          borderRadius: BorderRadius.circular(8),
          onTap: onPressed,
          child: Container(
            padding: const EdgeInsets.all(8),
            child: Icon(
              icon,
              size: 20,
              color: color ?? Theme.of(context).colorScheme.onSurface,
            ),
          ),
        ),
      ),
    );
  }
}
44
likes
140
points
76
downloads

Publisher

unverified uploader

Weekly Downloads

A lightweight, scalable, and highly customizable package that empowers Flutter developers to create dynamic, interactive, and visually appealing node-based UIs.

Homepage
Repository (GitHub)
View/report issues

Topics

#visual-scripting #workflow-editor #node-editor

Documentation

API reference

Funding

Consider supporting this project:

studio.buymeacoffee.com

License

MIT (license)

Dependencies

flutter, flutter_context_menu, flutter_shaders, uuid, vector_math

More

Packages that depend on fl_nodes