リレーションの描画調整と設定ダイアログ – 開発日記(15)

2023/04/17

  • あらためてアイディア同士をつなぐ線(リレーション)の描画に取りかかる。
    • 今回はCustomPaintではなくContainerを丸や線として直接配置している。自分で計算するようにしたらうまくいっている。スクロールしてもずれない。
    • なおRenderBoxの描画位置から求める方法だと、パンしたときにどんどんずれていってsいまった。また起動時のinitStateでContextが取得出来ずNullになってしまうため、起動直後の描画に失敗するなど、うまく対応できなかった
  • 拡大縮小処理についてはtransformではどうしてもうまくいかなかず、拡大縮小のみInteractiveViewerを挟んでいる。しかし縮小するとIdeaのサイズが勝手に変わってしまい描画がずれる。なかなか難しい

flutterのソースファイル:main.dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'logic_collection.dart';
import 'dart:math';
import 'dart:ui';
import 'dart:convert'; //JSON形式のデータを扱う
import 'package:path/path.dart' as path_dart; //asが無いとcontextを上書きされてしまう
import 'package:file_selector/file_selector.dart';

class MyCustomScrollBehavior extends MaterialScrollBehavior {
  @override
  Set<PointerDeviceKind> get dragDevices => {
    PointerDeviceKind.touch, // 通常のタッチ入力デバイス
    PointerDeviceKind.mouse, // これを追加!
  };
}

void main() => runApp(ProviderScope(child: MyApp()));

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      scrollBehavior: MyCustomScrollBehavior(),
      home: Scaffold(
        body: SafeArea(child:
          WholeZone(),
        ),
      ),
    );
  }
}

class WholeZone extends ConsumerStatefulWidget {
  @override
  _WholeZoneState createState() => _WholeZoneState();
}

class _WholeZoneState extends ConsumerState<WholeZone> {
  GlobalKey<CanvasZoneState> canvasZoneKey = GlobalKey();
  late CanvasZone canvasZone;
  double x = 0.0;
  double y = 0.0;

  @override
  void initState() {
    canvasZone = CanvasZone(key: canvasZoneKey);
    super.initState();
  }

  void _updateLocation(PointerEvent details) {
    setState(() {
      x = details.position.dx;
      y = details.position.dy;
    });
  }

  void addNewLane() => canvasZoneKey.currentState!.addNewLane();
  void removeLane() => canvasZoneKey.currentState?.removeLane();
  void test() => canvasZoneKey.currentState?.test();

  void scaleUp() {
    if (canvasZoneKey.currentState == null) return;
    TransformationController controller = canvasZoneKey.currentState!._transformationController;
    controller.value = Matrix4.identity()..scale(controller.value.getMaxScaleOnAxis() * 1.1);
  }

  void scaleDown() {
    if (canvasZoneKey.currentState == null) return;
    TransformationController controller = canvasZoneKey.currentState!._transformationController;
    controller.value = Matrix4.identity()..scale(controller.value.getMaxScaleOnAxis() * 0.9);
    canvasZoneKey.currentState?._scale = canvasZoneKey.currentState!._scale * 0.9;
  }

  void scaleReset() {
    if (canvasZoneKey.currentState == null) return;
    TransformationController controller = canvasZoneKey.currentState!._transformationController;
    controller.value = Matrix4.identity();
  }

  Future<void> loadDataWithDialog() async {
    const XTypeGroup typeGroup = XTypeGroup(
      label: 'json',
      extensions: ['json'],
    );
    final XFile? file = await openFile(acceptedTypeGroups: [typeGroup]);
    if (file != null) {
      await loadData(fileName: file.path, state: canvasZoneKey.currentState);
      //レーンの高さを取得
      ref.watch(lanesBodyCanvasHeight.notifier).state = canvasZoneKey.currentState!.getMaxLanesHight();
      //スクロールポジションを戻す
      canvasZoneKey.currentState!._scrollController.jumpTo(0);
    }
  }

  Future<void> saveAs() async {
    String? path = await getSavePath(
      acceptedTypeGroups: [
        const XTypeGroup(label: 'json', extensions: ['json'])
      ],
      suggestedName: "",
    );
    if (path != null) {
      if (path_dart.extension(path) == '') {
        path = '$path.json';
      }
      saveData(fileName: path, state: canvasZoneKey.currentState);
    }
  }

  @override
  Widget build(BuildContext context) {
    return MouseRegion(
      onHover: _updateLocation,
      child: Column(children: [
        Align(
          alignment: Alignment.topLeft,
          child: Container(
            margin: const EdgeInsets.only(top: 10),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: addNewLane,
                  child: const Text("レーン追加"),
                ),
                const SizedBox(width: 10),
                ElevatedButton(
                  onPressed: removeLane,
                  child: const Text("削除"),
                ),
                const SizedBox(width: 30),
                ElevatedButton(
                  onPressed: scaleUp,
                  child: const Text("拡大"),
                ),
                const SizedBox(width: 10),
                ElevatedButton(
                  onPressed: scaleDown,
                  child: const Text("縮小"),
                ),
                const SizedBox(width: 10),
                ElevatedButton(
                  onPressed: scaleReset,
                  child: const Text("リセット"),
                ),
                const SizedBox(width: 30),
                ElevatedButton(
                  onPressed: saveAs,
                  child: const Text("名前を付けて保存"),
                ),
                const SizedBox(width: 10),
                ElevatedButton(
                  onPressed: loadDataWithDialog,
                  child: const Text("ファイルを読込"),
                ),
                const SizedBox(width: 30),
                ElevatedButton(
                  onPressed: test,
                  child: const Text("テスト"),
                ),
                const SizedBox(width: 30),
                Container( child: Text("x:${x.toInt().toString().padLeft(5,'0')}, y:${y.toInt().toString().padLeft(5,'0')} ") ),
              ],
            ),
          ),
        ),
        const Divider(),
        Expanded(
          child: canvasZone,
        ),
      ]),
    );
  }
}

class IdeasRelation {
  Idea origin;
  Idea related;
  IdeasRelation({required this.origin, required this.related});
}

class CanvasZone extends ConsumerStatefulWidget {
  @override
  GlobalKey<CanvasZoneState> key;

  CanvasZone({required GlobalKey<CanvasZoneState> this.key}) : super(key: key);

  @override
  CanvasZoneState createState() => CanvasZoneState();
}

class CanvasZoneState extends ConsumerState<CanvasZone> {
  double widthA = double.infinity;
  List<LaneBody> lanes = [];
  List<LaneHeader> laneHeaders = [];
  final counterForReaint = ValueNotifier<int>(0);
  TransformationController _transformationController = TransformationController();
  GlobalKey _interactiveViewerKey = GlobalKey();
  double globalTop = 0.0;
  final _scrollController = ScrollController();
  double _scale = 1.0;

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

    getMaxLanesHight();

    WidgetsBinding.instance.addPostFrameCallback((_) {
      //CanvasのTop座標を取得(スクロール制御用)
      globalTop = (widget.key.currentContext?.findRenderObject() as RenderBox).localToGlobal(Offset.zero).dy;

      //レーンの幅を決定
      ref.watch(eachLaneWidth.notifier).state = MediaQuery.of(context).size.width * 0.4;

      lanes[0].ideas[0].key.currentState?._textFocusNode.requestFocus();
    });
  }

  void test() {
    setState(() {});
  }

  double getMaxLanesHight() {
    int maxIdeaLength = 1;
    lanes.forEach((lane) {
      if (lane.ideas.length > maxIdeaLength ) {
        maxIdeaLength = lane.ideas.length;
      }
    });
    return maxIdeaLength * ref.read(eachIdeaHeight);
  }

  Future<void> loadDataAtStartUp() async {
    await loadData(state: this);

    //レーンの高さを取得
    ref.watch(lanesBodyCanvasHeight.notifier).state = getMaxLanesHight();
  }

  Future<void> addNewLaneAndFocusTitle(int targetLaneIndex) async {
    if (targetLaneIndex == lanes.length) {
      await addNewLane();
    }
    // ウィジェットが構築されるのを待つ
    WidgetsBinding.instance.addPostFrameCallback((_) {
      lanes[targetLaneIndex].key.currentState?._titleFocusNode.requestFocus();
    });
  }

  Future<void> addNewLane() async {
    setState(() {
      var index = lanes.length;
      var newLane = LaneBody(index: index, ideas: [Idea(index: 0)]);
      lanes.add(newLane);
      newLane.ideas[0].parentLane = newLane;
      var newLaneHeader = LaneHeader(index: index, relatedLane: newLane);
      laneHeaders.add(newLaneHeader);
    });
    WidgetsBinding.instance.addPostFrameCallback((_) {
      lanes[lanes.length - 1].ideas[0].key.currentState?._textFocusNode.requestFocus();
    });
  }

  void removeLane() {
    setState(() {
      if (lanes.length > 1) {
        lanes[lanes.length - 1].ideas.forEach((idea) {
          lanes[lanes.length - 1].key.currentState?.removeFromRelation(idea);
        });
        lanes.removeLast();
        laneHeaders.removeLast();
      }
    });
  }

  void focusNextLane(int targetLaneIndex, int targetIdeaIndex) {
    if (targetLaneIndex < 0 || targetIdeaIndex < 0) return;
    if (targetLaneIndex == lanes.length) {
      addNewLane();
    }
    if (targetLaneIndex < lanes.length) {
      for (int i = lanes[targetLaneIndex].ideas.length - 1; i < targetIdeaIndex; i++) {
        if (lanes[targetLaneIndex].key.currentState == null) {
          setState(() {
            //addNewIdeaが使えない状態なので、やむなく自力実装
            lanes[targetLaneIndex].ideas.add(Idea(index: i + 1));
            lanes[targetLaneIndex].ideas[lanes[targetLaneIndex].ideas.length - 1].parentLane = lanes[targetLaneIndex];
            for (int j = 0; j < lanes[targetLaneIndex].ideas.length; j++) {
              lanes[targetLaneIndex].ideas[j].index = j;
            }
          });
        } else {
          lanes[targetLaneIndex].key.currentState?.addNewIdea(i);
        }
      }
      lanes[targetLaneIndex].ideas[targetIdeaIndex].key.currentState?._textFocusNode.requestFocus();
    }
  }

  // 関連付けの線を描画する
  void _relationLinePaint(Canvas canvas, Size size) {
    relationLinePaint(canvas, _transformationController, widget, _interactiveViewerKey);
    //relationLinePaintForDebug(canvas, widget);
  }

  @override
  Widget build(BuildContext context) {
    return CanvasZoneInheritedWidget(
      canvasZoneState: this,
      child: DefaultTextStyle.merge(
        style: TextStyle(color: Colors.white),
        child: Align(
          alignment: Alignment.topLeft,
          child: InteractiveViewer(
            key: _interactiveViewerKey,
            constrained: false,
            panEnabled: false,
            scaleEnabled: false,
            alignment: Alignment.topLeft,
            transformationController: _transformationController,
            minScale: 1.0,
            maxScale: 4.0,
              child: Container(
                //Canvasのサイズ
                width: MediaQuery.of(context).size.width * 0.4 * lanes.length,
                height: MediaQuery.of(context).size.height, //double.infinity,
                color: Colors.transparent, // カラーを指定しないと上のGestureDetectorの範囲がchildの範囲に収まってしまう
                child: Column(
                  children: [
                    Align(
                      alignment: Alignment.topLeft,
                      child: SizedBox( // タイトル行
                        width: ref.watch(eachLaneWidth) * lanes.length / (_scale),
                        height: 50,
                        child: Stack(
                          children: [
                            Positioned(
                              top:  0,
                              left: ref.watch(canvasScrollDeltaX),
                              width: ref.watch(eachLaneWidth) * lanes.length / (_scale),
                              height: ref.watch(headerHeight),
                              child: Row(
                                children: laneHeaders,
                              ),
                            ),
                          ],
                        ),
                      ),
                    ),
                    Expanded(
                      child: Align(
                        alignment: Alignment.topLeft,
                        child: SingleChildScrollView(
                          controller: _scrollController,
                          child: SizedBox(
                            width: ref.watch(eachLaneWidth) * lanes.length / (_scale),
                            height: ref.watch(lanesBodyCanvasHeight),
                            child: GestureDetector(
                              onPanUpdate: (details) { //タイトル行を一緒に動かす
                                setState(() {
                                  ref.watch(canvasScrollDeltaX.notifier).state += details.delta.dx;
                                  ref.watch(canvasScrollDeltaX.notifier).state = ref.watch(canvasScrollDeltaX).clamp(-ref.watch(eachLaneWidth) * lanes.length, 0);

                                  double targetY = _scrollController.position.pixels - details.delta.dy;

                                  double yRange;
                                  double wholeHeight = globalTop + ref.watch(headerHeight) + ref.watch(lanesBodyCanvasHeight);
                                  if(wholeHeight < MediaQuery.of(context).size.height) {
                                    yRange = 0;
                                  } else {
                                    yRange = wholeHeight - MediaQuery.of(context).size.height + 16  + 25 /*スクロールのバウンス用*/ ;
                                  }
                                  _scrollController.jumpTo(targetY.clamp(0, yRange));
                                });
                              },
                              child:Stack(
                                children: //[
                                  buildIdeasAndRelations(this, _scale),
/*
                                  Positioned(
                                    top: 0,
                                    left: 0,
                                    width: laneWidth * lanes.length,
                                    height: MediaQuery.of(context).size.height,
                                      child: Container(
                                        width: laneWidth * lanes.length,
                                        height: double.infinity,
                                        color: Colors.transparent, // カラーを指定しないと上のGestureDetectorの範囲がchildの範囲に収まってしまう
                                        child: CustomPaint(
                                          painter: _CustomPainterB(_relationLinePaint, counterForReaint),
                                          child: Container(
                                            width: 50,
                //                  canvasZoneKey.currentState!._transformationController;
                //                    controller.value = Matrix4.identity()..scale(controller.value.getMaxScaleOnAxis() * 1.1);widget.,
                                            height: MediaQuery.of(context).size.height,
                                          ),
                                      ),
                                    ),
                                  ),
*/
                                //],
                              ),
                            ),
                          ),
                        ),
                      ),
                    ),
                  ]),
              ),
          ),
        ),
      ),
//      ),
    );
  }
}

class CanvasZoneInheritedWidget extends InheritedWidget {
  final CanvasZoneState canvasZoneState;

  CanvasZoneInheritedWidget({required this.canvasZoneState, required Widget child}) : super(child: child);

  @override
  bool updateShouldNotify(CanvasZoneInheritedWidget oldWidget) => true;

  static CanvasZoneState of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<CanvasZoneInheritedWidget>()!.canvasZoneState;
  }
}

class LaneHeader extends ConsumerStatefulWidget {
  @override
  final GlobalKey<_LaneHeaderState> key = GlobalKey<_LaneHeaderState>();
  int index;
  String title;
  LaneBody relatedLane;
  final double indentWidth = 40; //インデントの下げ幅

  LaneHeader({
    required this.index,
    required this.relatedLane,
    this.title = '',
  }) : super(key: ValueKey(GlobalKey<_LaneHeaderState>().toString()));

  @override
  _LaneHeaderState createState() => _LaneHeaderState();
}

class _LaneHeaderState extends ConsumerState<LaneHeader> {
  final TextEditingController titleController = TextEditingController();
  final FocusNode _titleFocusNode = FocusNode();

  @override
  void initState() {
    super.initState();
    titleController.text = widget.title;
  }

  @override
  Widget build(BuildContext context) {
    return LaneHeaderInheritedWidget(
      laneHeaderState: this,
      child: SizedBox(
        height: 50,
        width: ref.watch(eachLaneWidth),
        child: Focus(
          onKey: (FocusNode node, RawKeyEvent event) {
            if (event.runtimeType == RawKeyDownEvent) {
              if (event.logicalKey == LogicalKeyboardKey.arrowDown) { //カーソルキー ↓
                widget.relatedLane.ideas[0].key.currentState?._textFocusNode.requestFocus();
              } else if (event.logicalKey == LogicalKeyboardKey.arrowRight && //カーソルキー →
                  titleController.selection.baseOffset == titleController.text.length) {
                CanvasZoneInheritedWidget.of(context).addNewLaneAndFocusTitle(widget.index + 1);
              } else if (event.logicalKey == LogicalKeyboardKey.arrowLeft && //カーソルキー ←
                  titleController.selection.baseOffset == 0) {
                if (widget.index > 0) {
                  CanvasZoneInheritedWidget.of(context)
                      .lanes[widget.index - 1]
                      .key
                      .currentState
                      ?._titleFocusNode
                      .requestFocus();
                }
              }
            }
            return KeyEventResult.ignored;
          },
          child: Container( //タイトル
            color: Colors.blue[50],
            padding: EdgeInsets.symmetric(horizontal: indentWidth),
            child: TextField(
              controller: titleController,
              focusNode: _titleFocusNode,
              decoration: InputDecoration(
                border: InputBorder.none,
                disabledBorder: const OutlineInputBorder(
                  borderSide: BorderSide(color: Colors.blue, width: 2.0),
                ),
                fillColor: Colors.blue[50],
                filled: true,
              ),
              style: TextStyle(fontSize: 18.0, fontWeight: FontWeight.bold, color: Colors.blue[900]),
              keyboardType: TextInputType.text,
              textInputAction: TextInputAction.done,
              onSubmitted: (value) {
                _titleFocusNode.unfocus();
              },
            ),
          )),
      ),
    );
  }
}

class LaneHeaderInheritedWidget extends InheritedWidget {
  final _LaneHeaderState laneHeaderState;

  LaneHeaderInheritedWidget({required this.laneHeaderState, required Widget child}) : super(child: child);

  @override
  bool updateShouldNotify(LaneHeaderInheritedWidget oldWidget) => true;

  static _LaneHeaderState of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<LaneHeaderInheritedWidget>()!.laneHeaderState;
  }
}

class LaneBody extends ConsumerStatefulWidget {
  @override
  final GlobalKey<_LaneBodyState> key = GlobalKey<_LaneBodyState>();
  int index;
  String title;
  List<Idea> ideas;

  LaneBody({
    required this.index,
    required this.ideas,
    this.title = '',
  }) : super(key: ValueKey(GlobalKey<_LaneBodyState>().toString()));

  @override
  _LaneBodyState createState() => _LaneBodyState();
}

class _LaneBodyState extends ConsumerState<LaneBody> {
  List<Idea> get ideas => widget.ideas;
  final TextEditingController titleController = TextEditingController();
  final FocusNode _titleFocusNode = FocusNode();

  void addNewIdea(int currentIndex) {
    setState(() {
      int currentIndentLevel = ideas[currentIndex].indentLevel;
      ideas.insert(
        currentIndex + 1,
        Idea(
          index: currentIndex + 1,
          parentLane: widget,
          textFieldHeight: 48.0,
          indentLevel: currentIndentLevel,
        ),
      );
      for (int i = currentIndex + 2; i < ideas.length; i++) {
        ideas[i].index = i;
      }
      ref.watch(lanesBodyCanvasHeight.notifier).state = CanvasZoneInheritedWidget.of(context).getMaxLanesHight();
    });

    WidgetsBinding.instance.addPostFrameCallback((_) {
      ideas[currentIndex + 1].key.currentState?._textFocusNode.requestFocus();
    });
  }

  void moveIdea(int currentIndex, int direction) {
    if (currentIndex + direction >= 0 && currentIndex + direction < ideas.length) {
      setState(() {
        Idea temp = ideas[currentIndex];
        ideas[currentIndex] = ideas[currentIndex + direction];
        ideas[currentIndex + direction] = temp;
        ideas[currentIndex].key.currentState?.updateIndex(currentIndex);
        ideas[currentIndex + direction].key.currentState?.updateIndex(currentIndex + direction);
      });
    }
  }

  void removeFromRelation(Idea targetIdea) {
    List<IdeasRelation> removeTargetList = [];
    if (ref.read(relatedIdeasList).isNotEmpty) {
      for (var relationSet in ref.read(relatedIdeasList)) {
        if (relationSet.origin == targetIdea || relationSet.related == targetIdea) {
          removeTargetList.add(relationSet);
        }
      }
    }
    if (removeTargetList.isNotEmpty) {
      for (var relationSet in removeTargetList) {
        ref.watch(relatedIdeasList.notifier).state.remove(relationSet);
      }
      CanvasZoneInheritedWidget.of(context).counterForReaint.value++;
    }
  }

  void removeIdea(int currentIndex) {
    if (ideas.length > 1) {
      setState(() {
        removeFromRelation(ideas[currentIndex]);
        ideas.removeAt(currentIndex);
        for (int i = currentIndex; i < ideas.length; i++) {
          ideas[i].index = i;
        }
        ideas[max(0, currentIndex - 1)].key.currentState?._textFocusNode.requestFocus();
      });
    }
  }

  void focusAdjacentIdea(int currentIndex, int direction) {
    if (currentIndex == 0 && direction <= 0) {
      FocusScope.of(context).requestFocus(_titleFocusNode);
    } else if (currentIndex + direction >= 0 && currentIndex + direction < ideas.length) {
      ideas[currentIndex + direction].key.currentState?._textFocusNode.requestFocus();
    }
  }

  void redrawAffectedIdeas(int startIndex, int endIndex) {
    if (startIndex < 0 || endIndex >= ideas.length) return;
    setState(() {
      for (int i = startIndex; i <= endIndex; i++) {
        //再描画を促す
        //ideas[i].key.currentState?.indentLevel = widget.key.currentState?.indentLevel ?? indentLevel;
      }
    });
  }

  @override
  void initState() {
    super.initState();
    titleController.text = widget.title;
  }

  @override
  Widget build(BuildContext context) {
    return LaneBodyInheritedWidget(
      laneState: this,
      child: SizedBox(
        width: double.infinity,
        height: (ref.watch(eachIdeaHeight) * ideas.length).toDouble(),
        child: Column(
          children: ideas
        ),
      ),
    );
  }
}

class LaneBodyInheritedWidget extends InheritedWidget {
  final _LaneBodyState laneState;

  LaneBodyInheritedWidget({required this.laneState, required Widget child}) : super(child: child);

  @override
  bool updateShouldNotify(LaneBodyInheritedWidget oldWidget) => true;

  static _LaneBodyState of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<LaneBodyInheritedWidget>()!.laneState;
  }
}

class Idea extends ConsumerStatefulWidget {
  @override
  final GlobalKey<_IdeaState> key = GlobalKey<_IdeaState>();
  int index;
  int indentLevel;
  final double boxSpacing=8.0; //他のエディタとの間隔
  final double textFieldHeight; //テキスト欄の高さ
  String content;
  LaneBody? parentLane;
  List<Idea> relatedIdeas = [];
  GlobalKey textFieldKey = GlobalKey();

  Idea({
    required this.index,
    this.parentLane,
    this.textFieldHeight = 48.0, //デフォルトは48
    this.indentLevel = 0,
    this.content = '',
  }) : super(key: ValueKey(GlobalKey<_IdeaState>().toString()));

  @override
  _IdeaState createState() => _IdeaState();
}

class _IdeaState extends ConsumerState<Idea> with AutomaticKeepAliveClientMixin<Idea> {
  final TextEditingController textController = TextEditingController();
  final FocusNode _textFocusNode = FocusNode();

  void updateIndex(int newIndex) {
    setState(() {
      widget.index = newIndex;
    });
  }

  // インデントの線を描画する
  void _indentLinePaint(Canvas canvas, Size size) {
    indentLinePaint(canvas, size, context, widget.index, widget.indentLevel, widget.textFieldHeight, widget.boxSpacing, indentWidth);
  }

  String toJson() {
    Map<String, dynamic> data = {
      'index': widget.index,
      'indentLevel': widget.indentLevel,
      'text': textController.text,
    };
    return json.encode(data);
  }

  @override
  void initState() {
    super.initState();
    textController.text = widget.content;
  }

  @override
  bool get wantKeepAlive => true; //これが無いと画面外から出たときに中身も消えてしまう

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return Focus(
        onKey: (FocusNode node, RawKeyEvent event) {
          if (event.runtimeType == RawKeyDownEvent) {
            if (event.logicalKey == LogicalKeyboardKey.capsLock) {
              // CapsLockキーを無視する条件を追加
              return KeyEventResult.ignored;
            } else if (event.isControlPressed) {
              //コントロールキー
              bool needRepaint = false;
              if (event.logicalKey == LogicalKeyboardKey.arrowUp) { //カーソルキー ↑
                LaneBodyInheritedWidget.of(context).moveIdea(widget.index, -1);
                needRepaint = true;
              } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
                LaneBodyInheritedWidget.of(context).moveIdea(widget.index, 1);
                needRepaint = true;
              }
              if (needRepaint) {
                findAndRepaintAffectedIdeas(context, widget.index, widget.indentLevel, widget.index,
                    widget.index + 1 /*Ctrlキーの場合、1つ下からチェックが必要*/);
              }
            } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
              //カーソルキー ↑
              LaneBodyInheritedWidget.of(context).focusAdjacentIdea(widget.index, -1);
            } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
              if (widget.index == LaneBodyInheritedWidget.of(context).ideas.length - 1) {
                LaneBodyInheritedWidget.of(context).addNewIdea(widget.index);
              } else {
                LaneBodyInheritedWidget.of(context).focusAdjacentIdea(widget.index, 1);
              }
            } else if (event.logicalKey == LogicalKeyboardKey.arrowRight && //カーソルキー →
                textController.selection.baseOffset == textController.text.length) {
              int laneIndex =
                  CanvasZoneInheritedWidget.of(context).lanes.indexOf(LaneBodyInheritedWidget.of(context).widget);
              CanvasZoneInheritedWidget.of(context).focusNextLane(laneIndex + 1, widget.index);
            } else if (event.logicalKey == LogicalKeyboardKey.arrowLeft && textController.selection.baseOffset == 0) {
              int laneIndex =
                  CanvasZoneInheritedWidget.of(context).lanes.indexOf(LaneBodyInheritedWidget.of(context).widget);
              CanvasZoneInheritedWidget.of(context).focusNextLane(laneIndex - 1, widget.index);
            } else if (event.logicalKey == LogicalKeyboardKey.tab) {
              //TABキー
              if (event.isShiftPressed) {
                setState(() {
                  widget.indentLevel = max(0, widget.indentLevel - 1);
                });
              } else {
                setState(() {
                  widget.indentLevel += 1;
                });
              }
              findAndRepaintAffectedIdeas(context, widget.index, widget.indentLevel, widget.index, widget.index);
              return KeyEventResult.handled; //デフォルトのタブの挙動を無効化して、フォーカスの移動を防ぐ
            } else if ((event.logicalKey == LogicalKeyboardKey.delete || //DEL & BSキー
                    event.logicalKey == LogicalKeyboardKey.backspace) &&
                textController.text.isEmpty) {
              LaneBodyInheritedWidget.of(context).removeIdea(widget.index);
            } else if (event.logicalKey == LogicalKeyboardKey.f1) {
              StateController<bool> mode = ref.watch(relatedIdeaSelectMode.notifier);
              mode.state = true;
              ref.watch(originIdeaForSelectMode.notifier).state = widget;
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(
                  content: Text(
                      'IdeaSelectMode:${mode.state} Lane:${CanvasZoneInheritedWidget.of(context).lanes.indexOf(LaneBodyInheritedWidget.of(context).widget)} Idea:${widget.index}'),
                ),
              );
            }
          }
          return KeyEventResult.ignored;
        },
        child: RawKeyboardListener(
          focusNode: FocusNode(),
          child: Container(
            margin: EdgeInsets.symmetric(horizontal: ideaHorizontalMargin, vertical: widget.boxSpacing),
            child: Row(
              crossAxisAlignment: CrossAxisAlignment.center,
              children: [
                const SizedBox(width: ideaLeftSpace, child: DecoratedBox(decoration: BoxDecoration(color: Colors.red))),
                Expanded(
                  flex: 4,
                  child: Row(children: [
                    CustomPaint(
                      painter: _CustomPainter(_indentLinePaint),
                      child: Container(width: indentWidth * widget.indentLevel),
                    ),
                    Expanded(
                      child: SizedBox(
                        height: widget.textFieldHeight,
                        child: TextField(
                          key: widget.textFieldKey,
                          controller: textController,
                          focusNode: _textFocusNode,
                          maxLines: 1,
                          decoration: const InputDecoration(
                            border: OutlineInputBorder(),
                          ),
                          keyboardType: TextInputType.multiline,
                          textInputAction: TextInputAction.done,
                          onSubmitted: (value) {
                            Offset? globalOffset;
                            if (ref.watch(relatedIdeaSelectMode.notifier).state == true) { //relatedの指定モード
                              StateController<bool> mode = ref.watch(relatedIdeaSelectMode.notifier);
                              Idea? origin = ref.watch(originIdeaForSelectMode.notifier).state;
                              if (origin != null) {
                                widget.relatedIdeas.add(origin);
                                ref.watch(relatedIdeasList.notifier).state.add(IdeasRelation(origin: origin, related: widget));
                                origin = null;
                                RenderBox? box = widget.key.currentContext?.findRenderObject() as RenderBox;
                                globalOffset = box.localToGlobal(Offset.zero);
                                CanvasZoneInheritedWidget.of(context).setState(() {}); //Relationの線を描画
                              }
                              ScaffoldMessenger.of(context).hideCurrentSnackBar();
                              ScaffoldMessenger.of(context).showSnackBar(
                                SnackBar(
                                  content: Text(
                                    'globaolOffset:$globalOffset | originIndex:${widget.relatedIdeas[0].index}  | Lane:${CanvasZoneInheritedWidget.of(context).lanes.indexOf(LaneBodyInheritedWidget.of(context).widget)} Idea:${widget.index}'),
                                ),
                              );
                              mode.state = false;
                            } else {
                              _textFocusNode.unfocus();
                              LaneBodyInheritedWidget.of(context).addNewIdea(widget.index);
                            }
                          },
                        ),
                      ),
                    ),
                  ]),
                ),
              ],
            ),
          ),
        ));
  }
}

class _CustomPainter extends CustomPainter {
  final void Function(Canvas, Size) _paint;
  _CustomPainter(this._paint);

  @override
  void paint(Canvas canvas, Size size) {
    _paint(canvas, size);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

class _CustomPainterB extends CustomPainter {
  final void Function(Canvas, Size) _paint;

  _CustomPainterB(this._paint, Listenable repaint) : super(repaint: repaint);

  @override
  void paint(Canvas canvas, Size size) {
    _paint(canvas, size);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

logic_collection.dart (main.dartと同じフォルダに配置)

import 'package:flutter/material.dart';
import 'main.dart';
import 'dart:convert'; //JSON形式のデータを扱う
import 'package:path_provider/path_provider.dart';
import 'dart:io';
import 'dart:math';
import 'package:vector_math/vector_math_64.dart' as vector_math;
import 'package:flutter/rendering.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

//リレーションの情報
StateProvider<bool> relatedIdeaSelectMode = StateProvider((ref) => false);
StateProvider<Idea?> originIdeaForSelectMode = StateProvider((ref) => null);
StateProvider<List<IdeasRelation>> relatedIdeasList = StateProvider((ref) => []);

//スケール
StateProvider<double> currentScale = StateProvider((ref) => 1);

//各種ウィジェットのサイズ
StateProvider<double> headerHeight = StateProvider((ref) => 50);
StateProvider<double> eachLaneWidth = StateProvider((ref) => 500);
StateProvider<double> eachIdeaHeight = StateProvider((ref) => 64); //48+8+8
StateProvider<double> lanesBodyCanvasHeight = StateProvider((ref) => 500);
StateProvider<double> relationDotSize = StateProvider((ref) => 6);

const double ideaLeftSpace = 26;
const double ideaHorizontalMargin = 16;
const double indentWidth = 40;

//タイトル行のスクロール用
StateProvider<double> canvasScrollDeltaX = StateProvider((ref) => 0);
StateProvider<double> canvasScrollDeltaY = StateProvider((ref) => 0);


//2点間の距離を求める関数
double getDistance(double x, double y, double x2, double y2) {
  double distance = sqrt((x2 - x) * (x2 - x) + (y2 - y) * (y2 - y));
  return distance;
}
//2点間の角度を求める関数
double getRadian(double x, double y, double x2, double y2) {
  double radian = atan2(y2 - y,x2 - x);
  return radian;
}

List <Widget> buildIdeasAndRelations(CanvasZoneState state, double _scale) {
  List <Widget> list = [];
  final double laneWidth = state.ref.watch(eachLaneWidth);
  final double dotSize = state.ref.watch(relationDotSize);

  /* Ideas */
  list.add(
    Positioned( // Ideasの描画
      top:  0,
      left: 0 + state.ref.watch(canvasScrollDeltaX),
      width: state.ref.watch(eachLaneWidth) * state.lanes.length / _scale,
      height: state.ref.watch(lanesBodyCanvasHeight),
      child: ListView.builder(
        scrollDirection: Axis.horizontal,
        itemCount: state.lanes.length,
        itemBuilder: (context, index) {
          return SizedBox( // Laneごとの箱
            width: state.ref.watch(eachLaneWidth) / _scale,
            child: state.lanes[index],
          );
        },
      ),
    ),
  );
  if (state.widget.key.currentState == null) return list;
  if (state.widget.key.currentState!.lanes.isEmpty) return list;
  if (state.widget.key.currentState!.lanes[0].ideas.isEmpty) return list;
//  if (state.widget.key.currentState!.lanes[0].key.currentContext == null) return list;

  /* Relation */
  for (var relationSet in state.ref.watch(relatedIdeasList)) {

    // leftを必ずstartIdeaとしてしまう。左右逆のパターンもロジックを記述すると、回転の計算などが面倒になったため
    Idea startIdea;
    Idea destinationIdea;;
    if (relationSet.origin.parentLane!.index <= relationSet.related.parentLane!.index ) {
      startIdea  = relationSet.origin;
      destinationIdea = relationSet.related;
    } else {
      startIdea  = relationSet.related;
      destinationIdea = relationSet.origin;
    }

    // 描画位置から求めると、パン・スクロールしたときにどんどんずれていく
    // RenderBox originIdeaTextFieldBox = origin.textFieldKey.currentContext?.findRenderObject() as RenderBox;
    double startY = state.globalTop - state.ref.watch(headerHeight) + ( state.ref.watch(eachIdeaHeight) * startIdea.index ) + ( state.ref.watch(eachIdeaHeight) / 2 ) -  (state.ref.watch(relationDotSize));
    double startX = (startIdea.parentLane!.index + 1) * state.ref.watch(eachLaneWidth) - ideaHorizontalMargin;

    /* Related */
    double endY = state.globalTop - state.ref.watch(headerHeight) + ( state.ref.watch(eachIdeaHeight) * destinationIdea.index ) + ( state.ref.watch(eachIdeaHeight) / 2 ) -  (state.ref.watch(relationDotSize));
    double endX = laneWidth * (destinationIdea.parentLane!.index) + ideaHorizontalMargin + ideaLeftSpace + (indentWidth * destinationIdea.indentLevel);

    if (startIdea.parentLane!.index < destinationIdea.parentLane!.index && destinationIdea.indentLevel >= 1) { //Relatedが右側にあり、インデントされている場合
      //relatedIdeaY += 5; //Related側のインデントの線と重なるのを避けるために少しずらす
    }

    list.add( //ドットの描画 Origin
      Positioned(
        top:  startY,
        left: startX - (dotSize/2) + state.ref.watch(canvasScrollDeltaX),
        child: Container(
          width: dotSize,
          height: dotSize,
          decoration: BoxDecoration(
            color: Colors.blue,
            shape: BoxShape.circle,
          ),
        ),
      ),
    );
    list.add( //ドットの描画 Relation
      Positioned(
        top:  endY,
        left: endX - (dotSize/2) + state.ref.watch(canvasScrollDeltaX),
        child: Container(
          width: dotSize,
          height: dotSize,
          decoration: BoxDecoration(
            color: Colors.blue,
            shape: BoxShape.circle,
          ),
        ),
      ),
    );
    list.add( //線の描画
      Positioned(
        top:  startY + (dotSize/2) -1/*線の太さ分を調整*/,
        left: startX + state.ref.watch(canvasScrollDeltaX)  - (dotSize/2) + (dotSize/2),
        child: Transform.rotate(
          angle: getRadian(startX + state.ref.watch(canvasScrollDeltaX), startY, endX + state.ref.watch(canvasScrollDeltaX), endY),
          alignment: Alignment.topLeft,
//          origin: Offset( state.ref.watch(relationDotSize)/2 , state.ref.watch(relationDotSize)/2 ),
          child: Container(
            width: getDistance(startX - (dotSize/2) + state.ref.watch(canvasScrollDeltaX), startY, endX - (dotSize/2) + state.ref.watch(canvasScrollDeltaX), endY),
            height: 2,
            decoration: BoxDecoration(
              color: Colors.blue,
            ),
          ),
        ),
      ),
    );
/*
    //座標の表示
    RenderBox originIdeaBox = origin.key.currentContext?.findRenderObject() as RenderBox;
    list.add(
      Positioned(
        top:  originIdeaY - 10,
        left: originTextFieldX - (state.ref.watch(relationDotSize)/2) + state.ref.watch(canvasScrollDeltaX) + 10,
        child: Text(
            "globalTop:${state.globalTop} globalTop+headerHeight:${state.globalTop+ state.ref.watch(headerHeight)} state.widget.key.currentContext?.findRenderObject() as RenderBox).localToGlobal(Offset.zero).dy${(state.widget.key.currentContext?.findRenderObject() as RenderBox).localToGlobal(Offset.zero).dy}\n"+
            "originIdeaBox.localToGlobal(Offset.zero).dy:${originIdeaBox.localToGlobal(Offset.zero).dy.toInt()} originIdeaY:${originIdeaY.toInt()} originIdeaBox.GlobalToLocal(Offset.zero).dy:${originIdeaBox.globalToLocal(Offset.zero).dy.toInt()} ",
            style: TextStyle(color: Colors.blue),),
      ),
    );
*/

  }
  return list;
}

class relationWidgets extends ConsumerStatefulWidget {
  const relationWidgets({Key? key}) : super(key: key);

  @override
  _relationWidgetsState createState() => _relationWidgetsState();
}

class _relationWidgetsState extends ConsumerState<relationWidgets> {
  @override
  Widget build(BuildContext context) {
    for (var relationSet in ref.watch(relatedIdeasList)) {
      Idea origin = relationSet.origin;
      Idea related = relationSet.related;
      print("[_relationWidgetsState] origin lane:${origin.parentLane?.index},idea:${origin.index} , related lane:${related.parentLane?.index},idea:${related.index}");

      RenderBox originIdeaBox = origin.key.currentContext?.findRenderObject() as RenderBox;
      RenderBox originIdeaTextFieldBox = origin.textFieldKey.currentContext?.findRenderObject() as RenderBox;
      RenderBox relatedIdeaBox = related.key.currentContext?.findRenderObject() as RenderBox;
      RenderBox relatedIdeaTextFieldBox = related.textFieldKey.currentContext?.findRenderObject() as RenderBox;

      double originIdeaY = originIdeaBox.localToGlobal(Offset.zero).dy + (originIdeaBox.size.height / 2 );

      double originTextFieldX = originIdeaTextFieldBox.localToGlobal(Offset.zero).dx;
      if (origin.parentLane!.index < related.parentLane!.index) { //Originが左側なら、Originの右側を起点とする
        originTextFieldX += originIdeaTextFieldBox.size.width;
      }
    };

    return Positioned(
      top: 200,
      left: 300,
      child: Container(
        width: 8,
        height: 8,
        decoration: BoxDecoration(
          color: Colors.blue,
          shape: BoxShape.circle,
        ),
      ),
    );
  }
}

Offset getWidgetGlobalPositionInInteractiveViewer(GlobalKey widgetKey, TransformationController transformationController, GlobalKey interactiveViewerKey) {

  // 変換行列を取得
  final matrix = transformationController.value;
  // ウィジェットのRenderBoxを取得
  final renderBox = widgetKey.currentContext?.findRenderObject() as RenderBox;
  // ウィジェットのローカル座標を取得
  final localOffset = renderBox.localToGlobal(Offset.zero);
  // ウィジェットのグローバル座標を計算
  final globalOffset = MatrixUtils.transformPoint(Matrix4.inverted(transformationController.value),localOffset);
  return globalOffset;
}

Offset matrix4_transform_point(Matrix4 matrix, Offset point) {
  final vector_math.Vector3 transformed = matrix * vector_math.Vector3(point.dx, point.dy, 0);
  return Offset(transformed.x, transformed.y);
}

//関連付けの線を描画する
void relationLinePaint(Canvas canvas,TransformationController transformationController,CanvasZone widget, GlobalKey interactiveViewerKey) {
  final paint = Paint()
    ..color = Colors.blue
    ..strokeWidth = 2;

  if (widget.key.currentState == null) return;
  if (widget.key.currentState!.lanes.isEmpty) return;
  if (widget.key.currentState!.lanes[0].ideas.isEmpty) return;
  if (widget.key.currentState!.lanes[0].key.currentContext == null) return;

  if (widget.key.currentState!.ref.watch(relatedIdeasList).isNotEmpty) {
    for (var relationSet in widget.key.currentState!.ref.watch(relatedIdeasList)) {
      Idea origin = relationSet.origin;
      Idea related = relationSet.related;
      print("[relationLinePaint] origin lane:${origin.parentLane?.index},idea:${origin.index} , related lane:${related.parentLane?.index},idea:${related.index}");

//      RenderBox lane0Box = widget.key.currentState!.lanes[0].key.currentContext?.findRenderObject() as RenderBox;
      RenderBox originIdeaBox = origin.key.currentContext?.findRenderObject() as RenderBox;
      RenderBox originIdeaTextFieldBox = origin.textFieldKey.currentContext?.findRenderObject() as RenderBox;
      RenderBox relatedIdeaBox = related.key.currentContext?.findRenderObject() as RenderBox;
      RenderBox relatedIdeaTextFieldBox = related.textFieldKey.currentContext?.findRenderObject() as RenderBox;

/*
      final paint1 = Paint()
        ..color = Colors.blue;
      canvas.drawCircle(getWidgetGlobalPositionInInteractiveViewer(origin.textFieldKey, transformationController, interactiveViewerKey), 5, paint1);
*/

//      double originIdeaY = originIdeaBox.localToGlobal(Offset.zero).dy - lane0Box.localToGlobal(Offset.zero).dy + (originIdeaBox.size.height / 2 );
      double originIdeaY = originIdeaBox.localToGlobal(Offset.zero).dy + (originIdeaBox.size.height / 2 );

      double originTextFieldX = originIdeaTextFieldBox.localToGlobal(Offset.zero).dx;
      if (origin.parentLane!.index < related.parentLane!.index) { //Originが左側なら、Originの右側を起点とする
        originTextFieldX += originIdeaTextFieldBox.size.width;
      }

//      canvas.drawCircle(Offset(originTextFieldX, originIdeaY) , 3, paint);
      canvas.drawCircle(matrix4_transform_point(Matrix4.inverted(transformationController.value),Offset(originTextFieldX, originIdeaY)) , 3, paint);


/*
      final paint2 = Paint()
        ..color = Colors.orange;
      Offset a = MatrixUtils.transformPoint(Matrix4.inverted(transformationController.value),Offset(originTextFieldX, originIdeaY));
      canvas.drawCircle(a , 3, paint2);

      final paint3 = Paint()
        ..color = Colors.purpleAccent;
      Offset b = matrix4_transform_point(Matrix4.inverted(transformationController.value),Offset(originTextFieldX, originIdeaY));
      canvas.drawCircle(b , 5, paint3);
*/


//      double relatedIdeaY = relatedIdeaBox.localToGlobal(Offset.zero).dy - lane0Box.localToGlobal(Offset.zero).dy + (relatedIdeaBox.size.height / 2 );
      double relatedIdeaY = relatedIdeaBox.localToGlobal(Offset.zero).dy - (relatedIdeaBox.size.height / 2 );
      double relatedTextFieldX = relatedIdeaTextFieldBox.localToGlobal(Offset.zero).dx;
      if (origin.parentLane!.index > related.parentLane!.index) { //Originが右側なら、Relatedの右側を起点とする
        relatedTextFieldX += relatedIdeaTextFieldBox.size.width;
      }
      if (origin.parentLane!.index < related.parentLane!.index && related.indentLevel >= 1) { //Relatedが右側にあり、インデントされている場合
        relatedIdeaY += 5; //Related側のインデントの線と重なるのを避けるために少しずらす
      }

//      canvas.drawCircle(Offset(relatedTextFieldX, relatedIdeaY) , 3, paint);
//      canvas.drawLine(Offset(originTextFieldX, originIdeaY), Offset(relatedTextFieldX, relatedIdeaY), paint);
      canvas.drawCircle(matrix4_transform_point(Matrix4.inverted(transformationController.value),Offset(relatedTextFieldX, relatedIdeaY)) , 3, paint);
      canvas.drawLine(matrix4_transform_point(Matrix4.inverted(transformationController.value),Offset(originTextFieldX, originIdeaY)), matrix4_transform_point(Matrix4.inverted(transformationController.value),Offset(relatedTextFieldX, relatedIdeaY)), paint);

    }
  }
}

void relationLinePaintForDebug(Canvas canvas, CanvasZone widget) {
  //ツールバーの下:Offset(0,0) 又は canvasZone..RenderObject()のTop
  //lanes[0]..RenderObject().localToGlobalのTopは、なぜかIdeasのTopの位置あたりになってしまう(タイトルより下)
  //lanes[0].ideas[0]..RenderObject().localToGlobalのTopは、中途半端に2つめのIdeasのTopのあたりになってしまう
  //とりあえずtitle分下にずらし、idea[0]のTopの位置を求めるには下記で行けそう、
  //allIdeaTopY = idea0box.localToGlobal(Offset.zero).dy - lane0box.localToGlobal(Offset.zero).dy;

  final paint = Paint()
    ..color = Colors.black;
  canvas.drawCircle(const Offset(10,0), 5, paint);

  paint.color = Colors.purple;
  canvas.drawCircle( (widget.key.currentContext?.findRenderObject() as RenderBox).localToGlobal(Offset.zero), 5, paint);

  if (widget.key.currentState == null) return;
  if (widget.key.currentState!.lanes.isEmpty) return;
  if (widget.key.currentState!.lanes[0].ideas.isEmpty) return;

  RenderBox lane0Box = widget.key.currentState!.lanes[0].key.currentContext?.findRenderObject() as RenderBox;
  RenderBox idea0Box = widget.key.currentState!.lanes[0].ideas[0].key.currentContext?.findRenderObject() as RenderBox;
  RenderBox idea0TextFieldBox = widget.key.currentState!.lanes[0].ideas[0].textFieldKey.currentContext?.findRenderObject() as RenderBox;

  paint.color = Colors.red;
  canvas.drawCircle(lane0Box.localToGlobal(Offset.zero), 5, paint);

  paint.color = Colors.green;
  canvas.drawCircle(idea0Box.localToGlobal(Offset.zero), 10, paint);

  paint.color = Colors.yellow;
  double allIdeaTopY = idea0Box.localToGlobal(Offset.zero).dy - lane0Box.localToGlobal(Offset.zero).dy;
  double textFieldLeftX = idea0TextFieldBox.localToGlobal(Offset.zero).dx;
  canvas.drawCircle(Offset(textFieldLeftX + idea0TextFieldBox.size.width, allIdeaTopY + (idea0Box.size.height / 2 )) , 5, paint);
}

void indentLinePaint(Canvas canvas,Size size,BuildContext context, int index, int indentLevel, double textFieldHeight, double boxSpacing, double indentWidth) {
  // インデントの線を描画する
  final paint = Paint()
    ..color = Colors.grey
    ..strokeWidth = 1;
  var leftX = indentWidth / 2;

  // 一行上のIdeaのindentLevelを取得する
  int upperIndentLevel = 0;
  int upperIndex = index - 1;

  //描画:Canvasの起点は左側センター
  if (indentLevel > 0 && index > 0 /*一番上の時はツリーを描画しない*/) {
    //一番上の親が居ない場合の特別対応:自分の上のどこかに、indentLevelが小さい親がいるかチェック
    bool cancelFlg = false; //描画しない特別な場合用
    var i = index - 1;
    while (i >= 0) {
      if ((LaneBodyInheritedWidget.of(context)
          .ideas[i].indentLevel) <
          indentLevel) {
        break;
      }
      i--;
    }
    if (i == -1) cancelFlg = true;

    if (!cancelFlg) {
      //縦の線:左側の基準
      leftX = leftX + (indentWidth * (indentLevel - 1));

      //縦の線:自分の領域内で上に引く
      double startY =
          0 - (textFieldHeight / 2) - (boxSpacing * 2);

      //縦の線:自分の領域外でどのくらい上まで引くかを計算
      upperIndentLevel = LaneBodyInheritedWidget.of(context)
          .ideas[upperIndex].indentLevel;
      if (indentLevel <= upperIndentLevel) {
        while (indentLevel < upperIndentLevel && upperIndex >= 0) {
          startY -= (textFieldHeight + boxSpacing * 2);
          upperIndex--;
          upperIndentLevel = LaneBodyInheritedWidget.of(context)
              .ideas[upperIndex].indentLevel;
        }
        if (indentLevel <= upperIndentLevel) {
          startY -= textFieldHeight / 2;
        }
      }

      //縦の線
      canvas.drawLine(Offset(leftX, 0), Offset(leftX, startY), paint);

      //横の線
      canvas.drawLine(Offset(leftX, size.height),
          Offset(indentWidth * indentLevel, size.height), paint);
    }
  }
}

void findAndRepaintAffectedIdeas(BuildContext context, int myIndex, int myIndentLevel, int startIndex, int endIndex){
  // Find the start index
  for (int i = myIndex - 1; i >= 0; i--) {
    if (LaneBodyInheritedWidget.of(context)
        .ideas[i].indentLevel == myIndentLevel - 1) {
      startIndex = i;
      break;
    }
  }
  // Find the end index
  for (int i = myIndex + 1;
  i < LaneBodyInheritedWidget.of(context).ideas.length;
  i++) {
    if ((LaneBodyInheritedWidget.of(context)
        .ideas[i].indentLevel) >= myIndentLevel) {
      endIndex = i;
      break;
    }
  }
  LaneBodyInheritedWidget.of(context).redrawAffectedIdeas(startIndex, endIndex);
  CanvasZoneInheritedWidget.of(context).counterForReaint.value++;
  CanvasZoneInheritedWidget.of(context).setState(() {}); //Relationの線を描画
}

Future<String> get _defaultFileName async {
  final directory = await getApplicationDocumentsDirectory();
  return '${directory.path}/default.json';
}

Future<void> saveData({String? fileName, required CanvasZoneState? state}) async {
  List<Map<String, dynamic>> lanesJson = [];

  fileName ??= await _defaultFileName;

  if(state == null) return;

  for (var lane in state.lanes ) {
    List<Map<String, dynamic>> ideasJson = [];
    for (var idea in lane.ideas) {
      ideasJson.add({
        'index': idea.index,
        'indentLevel': idea.indentLevel,
        'content': idea.key.currentState?.textController.text ?? '',
      });
    }
    lanesJson.add({
      'key': lane.key.toString(),
      'index': lane.index,
      'title': state.laneHeaders[lane.index].key.currentState?.titleController.text ?? '',
      'ideas': ideasJson,
    });
  }

  List<Map<String, dynamic>> relatedIdeasJson = [];
  state.ref.read(relatedIdeasList).forEach((relationSet) {
    relatedIdeasJson.add({
      'origin_lane_index': relationSet.origin.parentLane?.index,
      'origin_idea_key': relationSet.origin.key.toString(),
      'origin_idea_index': relationSet.origin.index,
      'related_lane_index': relationSet.related.parentLane?.index,
      'related_idea_key': relationSet.related.key.toString(),
      'related_idea_index': relationSet.related.index,
    });
  });

  String jsonString = jsonEncode({'lanes': lanesJson, 'relatedIdeas': relatedIdeasJson});
  await File(fileName).writeAsString(jsonString);
}

Future<void> loadData({String? fileName, required CanvasZoneState? state}) async {
  fileName ??= await _defaultFileName;
  final file = File(fileName);
  if (await file.exists() == false) {
    print('No such files:{$fileName}');
    return;
  }
  String jsonString = await file.readAsString();
  Map<String, dynamic> jsonMap = jsonDecode(jsonString);

  if(state == null) return;

  List<LaneBody> loadedLanes = [];
  List<LaneHeader> loadedLaneHeaders = [];
  for (var laneJson in jsonMap['lanes']) {
    List<Idea> loadedIdeas = [];

    var newLane = LaneBody(
      index: laneJson['index'] ?? 0,
      title: laneJson['title'] ?? '',
      ideas: loadedIdeas,
    );
    var newLaneHeader = LaneHeader(index: laneJson['index'] ?? 0, relatedLane: newLane);
    newLaneHeader.title = laneJson['title'] ?? '';
    loadedLaneHeaders.add(newLaneHeader);

    for (var ideaJson in laneJson['ideas']) {
      loadedIdeas.add(Idea(
        index: ideaJson['index'],
        indentLevel: ideaJson['indentLevel'],
        parentLane: newLane,
      ));
    }
    loadedLanes.add(newLane);
  }
  for (int i = 0; i < loadedLanes.length; i++) {
    for (int j = 0; j < loadedLanes[i].ideas.length; j++) {
      loadedLanes[i].ideas[j].content = jsonMap['lanes'][i]['ideas'][j]['content'];
    }
  }
  state.lanes = loadedLanes;
  state.laneHeaders = loadedLaneHeaders;
  state.ref.watch(relatedIdeasList.notifier).state.clear();

  if (jsonMap['relatedIdeas'] != null) {
    for (var relatedJson in jsonMap['relatedIdeas']) {
      var originLaneIndex = relatedJson['origin_lane_index'];
      var originIdeaIndex = relatedJson['origin_idea_index'];
      var relatedLaneIndex = relatedJson['related_lane_index'];
      var relatedIdeaIndex = relatedJson['related_idea_index'];
      IdeasRelation relationSet = IdeasRelation(
          origin: loadedLanes[originLaneIndex].ideas[originIdeaIndex], related: loadedLanes[relatedLaneIndex].ideas[relatedIdeaIndex]);
      state.ref.watch(relatedIdeasList.notifier).state.add(relationSet);
      print("[loadData] origin lane:$originLaneIndex,idea:$originIdeaIndex, related lane:$relatedLaneIndex,idea:$relatedIdeaIndex");
      loadedLanes[originLaneIndex].ideas[originIdeaIndex].relatedIdeas.add(loadedLanes[relatedLaneIndex].ideas[relatedIdeaIndex]);
    }
  }
}

pubspec.yaml

name: in_lane
description: A new Flutter project.
# The following line prevents the package from being accidentally published to
# pub.dev using `flutter pub publish`. This is preferred for private packages.
publish_to: 'none' # Remove this line if you wish to publish to pub.dev

# The following defines the version and build number for your application.
# A version number is three numbers separated by dots, like 1.2.43
# followed by an optional build number separated by a +.
# Both the version and the builder number may be overridden in flutter
# build by specifying --build-name and --build-number, respectively.
# In Android, build-name is used as versionName while build-number used as versionCode.
# Read more about Android versioning at https://developer.android.com/studio/publish/versioning
# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion.
# Read more about iOS versioning at
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+1

environment:
  sdk: '>=2.19.4 <3.0.0'

# Dependencies specify other packages that your package needs in order to work.
# To automatically upgrade your package dependencies to the latest versions
# consider running `flutter pub upgrade --major-versions`. Alternatively,
# dependencies can be manually updated by changing the version numbers below to
# the latest version available on pub.dev. To see which dependencies have newer
# versions available, run `flutter pub outdated`.
dependencies:
  flutter:
    sdk: flutter
  path_provider: ^2.0.7
  file_selector:
  flutter_riverpod:

  # The following adds the Cupertino Icons font to your application.
  # Use with the CupertinoIcons class for iOS style icons.
  cupertino_icons: ^1.0.2

dev_dependencies:
  flutter_test:
    sdk: flutter

  # The "flutter_lints" package below contains a set of recommended lints to
  # encourage good coding practices. The lint set provided by the package is
  # activated in the `analysis_options.yaml` file located at the root of your
  # package. See that file for information about deactivating specific lint
  # rules and activating additional ones.
  flutter_lints: ^2.0.0

# For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec

# The following section is specific to Flutter packages.
flutter:

  # The following line ensures that the Material Icons font is
  # included with your application, so that you can use the icons in
  # the material Icons class.
  uses-material-design: true

  # To add assets to your application, add an assets section, like this:
  # assets:
  #   - images/a_dot_burr.jpeg
  #   - images/a_dot_ham.jpeg

  # An image asset can refer to one or more resolution-specific "variants", see
  # https://flutter.dev/assets-and-images/#resolution-aware

  # For details regarding adding assets from package dependencies, see
  # https://flutter.dev/assets-and-images/#from-packages

  # To add custom fonts to your application, add a fonts section here,
  # in this "flutter" section. Each entry in this list should have a
  # "family" key with the font family name, and a "fonts" key with a
  # list giving the asset and other descriptors for the font. For
  # example:
  # fonts:
  #   - family: Schyler
  #     fonts:
  #       - asset: fonts/Schyler-Regular.ttf
  #       - asset: fonts/Schyler-Italic.ttf
  #         style: italic
  #   - family: Trajan Pro
  #     fonts:
  #       - asset: fonts/TrajanPro.ttf
  #       - asset: fonts/TrajanPro_Bold.ttf
  #         weight: 700
  #
  # For details regarding fonts from package dependencies,
  # see https://flutter.dev/custom-fonts/#from-packages

2023/04/18

  • リレーションの線上でマウスオーバーすると色が変わり、クリックでダイアログ表示する挙動を実装
  • 画面サイズ縮小時の描画ずれも修正

flutterのソースファイル:main.dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'logic_collection.dart';
import 'dart:math';
import 'dart:ui';
import 'dart:convert'; //JSON形式のデータを扱う
import 'package:path/path.dart' as path_dart; //asが無いとcontextを上書きされてしまう
import 'package:file_selector/file_selector.dart';

class LaneHeader extends ConsumerStatefulWidget {
  @override
  final GlobalKey<_LaneHeaderState> key = GlobalKey<_LaneHeaderState>();
  int index;
  String title;
  LaneBody relatedLane;
  final double indentWidth = 40; //インデントの下げ幅

  LaneHeader({
    required this.index,
    required this.relatedLane,
    this.title = '',
  }) : super(key: ValueKey(GlobalKey<_LaneHeaderState>().toString()));

  @override
  _LaneHeaderState createState() => _LaneHeaderState();
}

class CanvasZoneInheritedWidget extends InheritedWidget {
  final CanvasZoneState canvasZoneState;

  CanvasZoneInheritedWidget({required this.canvasZoneState, required Widget child}) : super(child: child);

  @override
  bool updateShouldNotify(CanvasZoneInheritedWidget oldWidget) => true;

  static CanvasZoneState of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<CanvasZoneInheritedWidget>()!.canvasZoneState;
  }
}

class MyCustomScrollBehavior extends MaterialScrollBehavior {
  @override
  Set<PointerDeviceKind> get dragDevices => {
    PointerDeviceKind.touch, // 通常のタッチ入力デバイス
    PointerDeviceKind.mouse, // これを追加!
  };
}

void main() => runApp(ProviderScope(child: MyApp()));

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      scrollBehavior: MyCustomScrollBehavior(),
      home: Scaffold(
        body: SafeArea(child:
          WholeZone(),
        ),
      ),
    );
  }
}

class WholeZone extends ConsumerStatefulWidget {
  @override
  _WholeZoneState createState() => _WholeZoneState();
}

class _WholeZoneState extends ConsumerState<WholeZone> {
  GlobalKey<CanvasZoneState> canvasZoneKey = GlobalKey();
  late CanvasZone canvasZone;
  double x = 0.0;
  double y = 0.0;

  @override
  void initState() {
    canvasZone = CanvasZone(key: canvasZoneKey);
    super.initState();
  }

  void _updateLocation(PointerEvent details) {
    setState(() {
      x = details.position.dx;
      y = details.position.dy;
    });
  }

  void addNewLane() => canvasZoneKey.currentState!.addNewLane();
  void removeLane() => canvasZoneKey.currentState?.removeLane();
  void test() => canvasZoneKey.currentState?.test();

  void scaleUp() {
    if (canvasZoneKey.currentState == null) return;
    TransformationController controller = canvasZoneKey.currentState!._transformationController;
    controller.value = Matrix4.identity()..scale(controller.value.getMaxScaleOnAxis() * 1.1);
  }

  void scaleDown() {
    if (canvasZoneKey.currentState == null) return;
    TransformationController controller = canvasZoneKey.currentState!._transformationController;
    controller.value = Matrix4.identity()..scale(controller.value.getMaxScaleOnAxis() * 0.9);
    canvasZoneKey.currentState?._scale = canvasZoneKey.currentState!._scale * 0.9;
  }

  void scaleReset() {
    if (canvasZoneKey.currentState == null) return;
    TransformationController controller = canvasZoneKey.currentState!._transformationController;
    controller.value = Matrix4.identity();
  }

  Future<void> loadDataWithDialog() async {
    const XTypeGroup typeGroup = XTypeGroup(
      label: 'json',
      extensions: ['json'],
    );
    final XFile? file = await openFile(acceptedTypeGroups: [typeGroup]);
    if (file != null) {
      await loadData(fileName: file.path, state: canvasZoneKey.currentState);
      //レーンの高さを取得
      ref.watch(lanesBodyCanvasHeight.notifier).state = canvasZoneKey.currentState!.getMaxLanesHight();
      //スクロールポジションを戻す
      canvasZoneKey.currentState!._scrollController.jumpTo(0);
    }
  }

  Future<void> saveAs() async {
    String? path = await getSavePath(
      acceptedTypeGroups: [
        const XTypeGroup(label: 'json', extensions: ['json'])
      ],
      suggestedName: "",
    );
    if (path != null) {
      if (path_dart.extension(path) == '') {
        path = '$path.json';
      }
      saveData(fileName: path, state: canvasZoneKey.currentState);
    }
  }

  @override
  Widget build(BuildContext context) {
    return MouseRegion(
      onHover: _updateLocation,
      child: Column(children: [
        Align(
          alignment: Alignment.topLeft,
          child: Container(
            margin: const EdgeInsets.only(top: 10),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                ElevatedButton(
                  onPressed: addNewLane,
                  child: const Text("レーン追加"),
                ),
                const SizedBox(width: 10),
                ElevatedButton(
                  onPressed: removeLane,
                  child: const Text("削除"),
                ),
                const SizedBox(width: 30),
                ElevatedButton(
                  onPressed: scaleUp,
                  child: const Text("拡大"),
                ),
                const SizedBox(width: 10),
                ElevatedButton(
                  onPressed: scaleDown,
                  child: const Text("縮小"),
                ),
                const SizedBox(width: 10),
                ElevatedButton(
                  onPressed: scaleReset,
                  child: const Text("リセット"),
                ),
                const SizedBox(width: 30),
                ElevatedButton(
                  onPressed: saveAs,
                  child: const Text("名前を付けて保存"),
                ),
                const SizedBox(width: 10),
                ElevatedButton(
                  onPressed: loadDataWithDialog,
                  child: const Text("ファイルを読込"),
                ),
                const SizedBox(width: 30),
                ElevatedButton(
                  onPressed: test,
                  child: const Text("テスト"),
                ),
                const SizedBox(width: 30),
                Container( child: Text("x:${x.toInt().toString().padLeft(5,'0')}, y:${y.toInt().toString().padLeft(5,'0')} ") ),
              ],
            ),
          ),
        ),
        const Divider(),
        Expanded(
          child: canvasZone,
        ),
      ]),
    );
  }
}

class IdeasRelation {
  Idea origin;
  Idea related;
  IdeasRelation({required this.origin, required this.related});
}

class CanvasZone extends ConsumerStatefulWidget {
  @override
  GlobalKey<CanvasZoneState> key;

  CanvasZone({required GlobalKey<CanvasZoneState> this.key}) : super(key: key);

  @override
  CanvasZoneState createState() => CanvasZoneState();
}

class CanvasZoneState extends ConsumerState<CanvasZone> {
  double widthA = double.infinity;
  List<LaneBody> lanes = [];
  List<LaneHeader> laneHeaders = [];
  final counterForReaint = ValueNotifier<int>(0);
  TransformationController _transformationController = TransformationController();
  GlobalKey _interactiveViewerKey = GlobalKey();
  double globalTop = 0.0;
  final _scrollController = ScrollController();
  double _scale = 1.0;
  double laneWidth = 100;

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

    getMaxLanesHight();

    WidgetsBinding.instance.addPostFrameCallback((_) {
      //CanvasのTop座標を取得(スクロール制御用)
      globalTop = (widget.key.currentContext?.findRenderObject() as RenderBox).localToGlobal(Offset.zero).dy;

      //レーンの幅を決定
      ref.watch(eachLaneWidth.notifier).state = MediaQuery.of(context).size.width * 0.4;
      laneWidth = MediaQuery.of(context).size.width * 0.4;

      lanes[0].ideas[0].key.currentState?._textFocusNode.requestFocus();
    });
  }

  void test() {
    setState(() {});
  }

  double getMaxLanesHight() {
    int maxIdeaLength = 1;
    lanes.forEach((lane) {
      if (lane.ideas.length > maxIdeaLength ) {
        maxIdeaLength = lane.ideas.length;
      }
    });
    return maxIdeaLength * ref.read(eachIdeaHeight);
  }

  Future<void> loadDataAtStartUp() async {
    await loadData(state: this);

    //レーンの高さを取得
    ref.watch(lanesBodyCanvasHeight.notifier).state = getMaxLanesHight();
  }

  Future<void> addNewLaneAndFocusTitle(int targetLaneIndex) async {
    if (targetLaneIndex == lanes.length) {
      await addNewLane();
    }
    // ウィジェットが構築されるのを待つ
    WidgetsBinding.instance.addPostFrameCallback((_) {
      lanes[targetLaneIndex].key.currentState?._titleFocusNode.requestFocus();
    });
  }

  Future<void> addNewLane() async {
    setState(() {
      var index = lanes.length;
      var newLane = LaneBody(index: index, ideas: [Idea(index: 0)]);
      lanes.add(newLane);
      newLane.ideas[0].parentLane = newLane;
      var newLaneHeader = LaneHeader(index: index, relatedLane: newLane);
      laneHeaders.add(newLaneHeader);
    });
    WidgetsBinding.instance.addPostFrameCallback((_) {
      lanes[lanes.length - 1].ideas[0].key.currentState?._textFocusNode.requestFocus();
    });
  }

  void removeLane() {
    setState(() {
      if (lanes.length > 1) {
        lanes[lanes.length - 1].ideas.forEach((idea) {
          lanes[lanes.length - 1].key.currentState?.removeFromRelation(idea);
        });
        lanes.removeLast();
        laneHeaders.removeLast();
      }
    });
  }

  void focusNextLane(int targetLaneIndex, int targetIdeaIndex) {
    if (targetLaneIndex < 0 || targetIdeaIndex < 0) return;
    if (targetLaneIndex == lanes.length) {
      addNewLane();
    }
    if (targetLaneIndex < lanes.length) {
      for (int i = lanes[targetLaneIndex].ideas.length - 1; i < targetIdeaIndex; i++) {
        if (lanes[targetLaneIndex].key.currentState == null) {
          setState(() {
            //addNewIdeaが使えない状態なので、やむなく自力実装
            lanes[targetLaneIndex].ideas.add(Idea(index: i + 1));
            lanes[targetLaneIndex].ideas[lanes[targetLaneIndex].ideas.length - 1].parentLane = lanes[targetLaneIndex];
            for (int j = 0; j < lanes[targetLaneIndex].ideas.length; j++) {
              lanes[targetLaneIndex].ideas[j].index = j;
            }
          });
        } else {
          lanes[targetLaneIndex].key.currentState?.addNewIdea(i);
        }
      }
      lanes[targetLaneIndex].ideas[targetIdeaIndex].key.currentState?._textFocusNode.requestFocus();
    }
  }

  // 関連付けの線を描画する
  void _relationLinePaint(Canvas canvas, Size size) {
    relationLinePaint(canvas, _transformationController, widget, _interactiveViewerKey);
    //relationLinePaintForDebug(canvas, widget);
  }

  @override
  Widget build(BuildContext context) {
    return CanvasZoneInheritedWidget(
      canvasZoneState: this,
      child: DefaultTextStyle.merge(
        style: TextStyle(color: Colors.white),
        child: Align(
          alignment: Alignment.topLeft,
          child: InteractiveViewer(
            key: _interactiveViewerKey,
            constrained: false,
            panEnabled: false,
            scaleEnabled: false,
            alignment: Alignment.topLeft,
            transformationController: _transformationController,
            minScale: 1.0,
            maxScale: 4.0,
              child: Container(
                //Canvasのサイズ
                width: MediaQuery.of(context).size.width * 0.4 * lanes.length,
                height: MediaQuery.of(context).size.height, //double.infinity,
                color: Colors.transparent, // カラーを指定しないと上のGestureDetectorの範囲がchildの範囲に収まってしまう
                child: Column(
                  children: [
                    Align( // タイトル行
                      alignment: Alignment.topLeft,
                      child: SizedBox(
                        width: ref.watch(eachLaneWidth) * lanes.length,
                        height: 50,
                        child: Stack(
                          children: [
                            Positioned(
                              top:  0,
                              left: ref.watch(canvasScrollDeltaX),
                              width: ref.watch(eachLaneWidth) * lanes.length,
                              height: ref.watch(headerHeight),
                              child: Row(
                                children: laneHeaders,
                              ),
                            ),
                          ],
                        ),
                      ),
                    ),
                    Expanded( // ボディ部
                      child: Align(
                        alignment: Alignment.topLeft,
                        child: SingleChildScrollView(
                          controller: _scrollController,
                          child: SizedBox(
                            width: ref.watch(eachLaneWidth) * lanes.length,
                            height: ref.watch(lanesBodyCanvasHeight),
                            child: GestureDetector(
                              onPanUpdate: (details) { //タイトル行を一緒に動かす
                                setState(() {
                                  ref.watch(canvasScrollDeltaX.notifier).state += details.delta.dx;
                                  ref.watch(canvasScrollDeltaX.notifier).state = ref.watch(canvasScrollDeltaX).clamp(-ref.watch(eachLaneWidth) * lanes.length, 0);

                                  double targetY = _scrollController.position.pixels - details.delta.dy;

                                  double yRange;
                                  double wholeHeight = globalTop + ref.watch(headerHeight) + ref.watch(lanesBodyCanvasHeight);
                                  if(wholeHeight < MediaQuery.of(context).size.height) {
                                    yRange = 0;
                                  } else {
                                    yRange = wholeHeight - MediaQuery.of(context).size.height + 16  + 25 /*スクロールのバウンス用*/ ;
                                  }
                                  _scrollController.jumpTo(targetY.clamp(0, yRange));
                                });
                              },
                              onTap: () async {
                                if (ref.watch(relationHoverFlg)) {
                                  await relationSelectDialog(context,this);
                                };
                              },
                              child:Stack(
                                children: //[
                                  buildIdeasAndRelations(this),
/*
                                  Positioned(
                                    top: 0,
                                    left: 0,
                                    width: laneWidth * lanes.length,
                                    height: MediaQuery.of(context).size.height,
                                      child: Container(
                                        width: laneWidth * lanes.length,
                                        height: double.infinity,
                                        color: Colors.transparent, // カラーを指定しないと上のGestureDetectorの範囲がchildの範囲に収まってしまう
                                        child: CustomPaint(
                                          painter: _CustomPainterB(_relationLinePaint, counterForReaint),
                                          child: Container(
                                            width: 50,
                //                  canvasZoneKey.currentState!._transformationController;
                //                    controller.value = Matrix4.identity()..scale(controller.value.getMaxScaleOnAxis() * 1.1);widget.,
                                            height: MediaQuery.of(context).size.height,
                                          ),
                                      ),
                                    ),
                                  ),
*/
                                //],
                              ),
                            ),
                          ),
                        ),
                      ),
                    ),
                  ]),
              ),
          ),
        ),
      ),
//      ),
    );
  }
}

class _LaneHeaderState extends ConsumerState<LaneHeader> {
  final TextEditingController titleController = TextEditingController();
  final FocusNode _titleFocusNode = FocusNode();

  @override
  void initState() {
    super.initState();
    titleController.text = widget.title;
  }

  @override
  Widget build(BuildContext context) {
    return LaneHeaderInheritedWidget(
      laneHeaderState: this,
      child: SizedBox(
        height: 50,
        width: ref.watch(eachLaneWidth),
        child: Focus(
          onKey: (FocusNode node, RawKeyEvent event) {
            if (event.runtimeType == RawKeyDownEvent) {
              if (event.logicalKey == LogicalKeyboardKey.arrowDown) { //カーソルキー ↓
                widget.relatedLane.ideas[0].key.currentState?._textFocusNode.requestFocus();
              } else if (event.logicalKey == LogicalKeyboardKey.arrowRight && //カーソルキー →
                  titleController.selection.baseOffset == titleController.text.length) {
                CanvasZoneInheritedWidget.of(context).addNewLaneAndFocusTitle(widget.index + 1);
              } else if (event.logicalKey == LogicalKeyboardKey.arrowLeft && //カーソルキー ←
                  titleController.selection.baseOffset == 0) {
                if (widget.index > 0) {
                  CanvasZoneInheritedWidget.of(context)
                      .lanes[widget.index - 1]
                      .key
                      .currentState
                      ?._titleFocusNode
                      .requestFocus();
                }
              }
            }
            return KeyEventResult.ignored;
          },
          child: Container( //タイトル
            color: Colors.blue[50],
            padding: EdgeInsets.symmetric(horizontal: indentWidth),
            child: TextField(
              controller: titleController,
              focusNode: _titleFocusNode,
              decoration: InputDecoration(
                border: InputBorder.none,
                disabledBorder: const OutlineInputBorder(
                  borderSide: BorderSide(color: Colors.blue, width: 2.0),
                ),
                fillColor: Colors.blue[50],
                filled: true,
              ),
              style: TextStyle(fontSize: 18.0, fontWeight: FontWeight.bold, color: Colors.blue[900]),
              keyboardType: TextInputType.text,
              textInputAction: TextInputAction.done,
              onSubmitted: (value) {
                _titleFocusNode.unfocus();
              },
            ),
          )),
      ),
    );
  }
}

class LaneHeaderInheritedWidget extends InheritedWidget {
  final _LaneHeaderState laneHeaderState;

  LaneHeaderInheritedWidget({required this.laneHeaderState, required Widget child}) : super(child: child);

  @override
  bool updateShouldNotify(LaneHeaderInheritedWidget oldWidget) => true;

  static _LaneHeaderState of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<LaneHeaderInheritedWidget>()!.laneHeaderState;
  }
}

class LaneBody extends ConsumerStatefulWidget {
  @override
  final GlobalKey<_LaneBodyState> key = GlobalKey<_LaneBodyState>();
  int index;
  String title;
  List<Idea> ideas;

  LaneBody({
    required this.index,
    required this.ideas,
    this.title = '',
  }) : super(key: ValueKey(GlobalKey<_LaneBodyState>().toString()));

  @override
  _LaneBodyState createState() => _LaneBodyState();
}

class _LaneBodyState extends ConsumerState<LaneBody> {
  List<Idea> get ideas => widget.ideas;
  final TextEditingController titleController = TextEditingController();
  final FocusNode _titleFocusNode = FocusNode();

  void addNewIdea(int currentIndex) {
    setState(() {
      int currentIndentLevel = ideas[currentIndex].indentLevel;
      ideas.insert(
        currentIndex + 1,
        Idea(
          index: currentIndex + 1,
          parentLane: widget,
          textFieldHeight: 48.0,
          indentLevel: currentIndentLevel,
        ),
      );
      for (int i = currentIndex + 2; i < ideas.length; i++) {
        ideas[i].index = i;
      }
      ref.watch(lanesBodyCanvasHeight.notifier).state = CanvasZoneInheritedWidget.of(context).getMaxLanesHight();
    });

    WidgetsBinding.instance.addPostFrameCallback((_) {
      ideas[currentIndex + 1].key.currentState?._textFocusNode.requestFocus();
    });
  }

  void moveIdea(int currentIndex, int direction) {
    if (currentIndex + direction >= 0 && currentIndex + direction < ideas.length) {
      setState(() {
        Idea temp = ideas[currentIndex];
        ideas[currentIndex] = ideas[currentIndex + direction];
        ideas[currentIndex + direction] = temp;
        ideas[currentIndex].key.currentState?.updateIndex(currentIndex);
        ideas[currentIndex + direction].key.currentState?.updateIndex(currentIndex + direction);
      });
    }
  }

  void removeFromRelation(Idea targetIdea) {
    List<IdeasRelation> removeTargetList = [];
    if (ref.read(relatedIdeasList).isNotEmpty) {
      for (var relationSet in ref.read(relatedIdeasList)) {
        if (relationSet.origin == targetIdea || relationSet.related == targetIdea) {
          removeTargetList.add(relationSet);
        }
      }
    }
    if (removeTargetList.isNotEmpty) {
      for (var relationSet in removeTargetList) {
        ref.watch(relatedIdeasList.notifier).state.remove(relationSet);
      }
      CanvasZoneInheritedWidget.of(context).counterForReaint.value++;
    }
  }

  void removeIdea(int currentIndex) {
    if (ideas.length > 1) {
      setState(() {
        removeFromRelation(ideas[currentIndex]);
        ideas.removeAt(currentIndex);
        for (int i = currentIndex; i < ideas.length; i++) {
          ideas[i].index = i;
        }
        ideas[max(0, currentIndex - 1)].key.currentState?._textFocusNode.requestFocus();
      });
    }
  }

  void focusAdjacentIdea(int currentIndex, int direction) {
    if (currentIndex == 0 && direction <= 0) {
      FocusScope.of(context).requestFocus(_titleFocusNode);
    } else if (currentIndex + direction >= 0 && currentIndex + direction < ideas.length) {
      ideas[currentIndex + direction].key.currentState?._textFocusNode.requestFocus();
    }
  }

  void redrawAffectedIdeas(int startIndex, int endIndex) {
    if (startIndex < 0 || endIndex >= ideas.length) return;
    setState(() {
      for (int i = startIndex; i <= endIndex; i++) {
        //再描画を促す
        //ideas[i].key.currentState?.indentLevel = widget.key.currentState?.indentLevel ?? indentLevel;
      }
    });
  }

  @override
  void initState() {
    super.initState();
    titleController.text = widget.title;
  }

  @override
  Widget build(BuildContext context) {
    return LaneBodyInheritedWidget(
      laneState: this,
      child: SizedBox(
        width: double.infinity,
        height: (ref.watch(eachIdeaHeight) * ideas.length).toDouble(),
        child: Column(
          children: ideas
        ),
      ),
    );
  }
}

class LaneBodyInheritedWidget extends InheritedWidget {
  final _LaneBodyState laneState;

  LaneBodyInheritedWidget({required this.laneState, required Widget child}) : super(child: child);

  @override
  bool updateShouldNotify(LaneBodyInheritedWidget oldWidget) => true;

  static _LaneBodyState of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<LaneBodyInheritedWidget>()!.laneState;
  }
}

class Idea extends ConsumerStatefulWidget {
  @override
  final GlobalKey<_IdeaState> key = GlobalKey<_IdeaState>();
  int index;
  int indentLevel;
  final double boxSpacing=8.0; //他のエディタとの間隔
  final double textFieldHeight; //テキスト欄の高さ
  String content;
  LaneBody? parentLane;
  List<Idea> relatedIdeas = [];
  GlobalKey textFieldKey = GlobalKey();

  Idea({
    required this.index,
    this.parentLane,
    this.textFieldHeight = 48.0, //デフォルトは48
    this.indentLevel = 0,
    this.content = '',
  }) : super(key: ValueKey(GlobalKey<_IdeaState>().toString()));

  @override
  _IdeaState createState() => _IdeaState();
}

class _IdeaState extends ConsumerState<Idea> with AutomaticKeepAliveClientMixin<Idea> {
  final TextEditingController textController = TextEditingController();
  final FocusNode _textFocusNode = FocusNode();

  void updateIndex(int newIndex) {
    setState(() {
      widget.index = newIndex;
    });
  }

  // インデントの線を描画する
  void _indentLinePaint(Canvas canvas, Size size) {
    indentLinePaint(canvas, size, context, widget.index, widget.indentLevel, widget.textFieldHeight, widget.boxSpacing, indentWidth);
  }

  String toJson() {
    Map<String, dynamic> data = {
      'index': widget.index,
      'indentLevel': widget.indentLevel,
      'text': textController.text,
    };
    return json.encode(data);
  }

  @override
  void initState() {
    super.initState();
    textController.text = widget.content;
  }

  @override
  bool get wantKeepAlive => true; //これが無いと画面外から出たときに中身も消えてしまう

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return Focus(
        onKey: (FocusNode node, RawKeyEvent event) {
          if (event.runtimeType == RawKeyDownEvent) {
            if (event.logicalKey == LogicalKeyboardKey.capsLock) {
              // CapsLockキーを無視する条件を追加
              return KeyEventResult.ignored;
            } else if (event.isControlPressed) {
              //コントロールキー
              bool needRepaint = false;
              if (event.logicalKey == LogicalKeyboardKey.arrowUp) { //カーソルキー ↑
                LaneBodyInheritedWidget.of(context).moveIdea(widget.index, -1);
                needRepaint = true;
              } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
                LaneBodyInheritedWidget.of(context).moveIdea(widget.index, 1);
                needRepaint = true;
              }
              if (needRepaint) {
                findAndRepaintAffectedIdeas(context, widget.index, widget.indentLevel, widget.index,
                    widget.index + 1 /*Ctrlキーの場合、1つ下からチェックが必要*/);
              }
            } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) {
              //カーソルキー ↑
              LaneBodyInheritedWidget.of(context).focusAdjacentIdea(widget.index, -1);
            } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) {
              if (widget.index == LaneBodyInheritedWidget.of(context).ideas.length - 1) {
                LaneBodyInheritedWidget.of(context).addNewIdea(widget.index);
              } else {
                LaneBodyInheritedWidget.of(context).focusAdjacentIdea(widget.index, 1);
              }
            } else if (event.logicalKey == LogicalKeyboardKey.arrowRight && //カーソルキー →
                textController.selection.baseOffset == textController.text.length) {
              int laneIndex =
                  CanvasZoneInheritedWidget.of(context).lanes.indexOf(LaneBodyInheritedWidget.of(context).widget);
              CanvasZoneInheritedWidget.of(context).focusNextLane(laneIndex + 1, widget.index);
            } else if (event.logicalKey == LogicalKeyboardKey.arrowLeft && textController.selection.baseOffset == 0) {
              int laneIndex =
                  CanvasZoneInheritedWidget.of(context).lanes.indexOf(LaneBodyInheritedWidget.of(context).widget);
              CanvasZoneInheritedWidget.of(context).focusNextLane(laneIndex - 1, widget.index);
            } else if (event.logicalKey == LogicalKeyboardKey.tab) {
              //TABキー
              if (event.isShiftPressed) {
                setState(() {
                  widget.indentLevel = max(0, widget.indentLevel - 1);
                });
              } else {
                setState(() {
                  widget.indentLevel += 1;
                });
              }
              findAndRepaintAffectedIdeas(context, widget.index, widget.indentLevel, widget.index, widget.index);
              return KeyEventResult.handled; //デフォルトのタブの挙動を無効化して、フォーカスの移動を防ぐ
            } else if ((event.logicalKey == LogicalKeyboardKey.delete || //DEL & BSキー
                    event.logicalKey == LogicalKeyboardKey.backspace) &&
                textController.text.isEmpty) {
              LaneBodyInheritedWidget.of(context).removeIdea(widget.index);
            } else if (event.logicalKey == LogicalKeyboardKey.f1) {
              StateController<bool> mode = ref.watch(relatedIdeaSelectMode.notifier);
              mode.state = true;
              ref.watch(originIdeaForSelectMode.notifier).state = widget;
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(
                  content: Text(
                      'IdeaSelectMode:${mode.state} Lane:${CanvasZoneInheritedWidget.of(context).lanes.indexOf(LaneBodyInheritedWidget.of(context).widget)} Idea:${widget.index}'),
                ),
              );
            }
          }
          return KeyEventResult.ignored;
        },
        child: RawKeyboardListener(
          focusNode: FocusNode(),
          child: Container(
            margin: EdgeInsets.symmetric(horizontal: ideaHorizontalMargin, vertical: widget.boxSpacing),
            child: Row(
              crossAxisAlignment: CrossAxisAlignment.center,
              children: [
                const SizedBox(width: ideaLeftSpace, child: DecoratedBox(decoration: BoxDecoration(color: Colors.red))),
                Expanded(
                  flex: 4,
                  child: Row(children: [
                    CustomPaint(
                      painter: _CustomPainter(_indentLinePaint),
                      child: Container(width: indentWidth * widget.indentLevel),
                    ),
                    Expanded(
                      child: SizedBox(
                        height: widget.textFieldHeight,
                        child: TextField(
                          key: widget.textFieldKey,
                          controller: textController,
                          focusNode: _textFocusNode,
                          maxLines: 1,
                          decoration: const InputDecoration(
                            border: OutlineInputBorder(),
                          ),
                          keyboardType: TextInputType.multiline,
                          textInputAction: TextInputAction.done,
                          onSubmitted: (value) {
                            Offset? globalOffset;
                            if (ref.watch(relatedIdeaSelectMode.notifier).state == true) { //relatedの指定モード
                              StateController<bool> mode = ref.watch(relatedIdeaSelectMode.notifier);
                              Idea? origin = ref.watch(originIdeaForSelectMode.notifier).state;
                              if (origin != null) {
                                widget.relatedIdeas.add(origin);
                                ref.watch(relatedIdeasList.notifier).state.add(IdeasRelation(origin: origin, related: widget));
                                origin = null;
                                RenderBox? box = widget.key.currentContext?.findRenderObject() as RenderBox;
                                globalOffset = box.localToGlobal(Offset.zero);
                                CanvasZoneInheritedWidget.of(context).setState(() {}); //Relationの線を描画
                              }
                              ScaffoldMessenger.of(context).hideCurrentSnackBar();
                              ScaffoldMessenger.of(context).showSnackBar(
                                SnackBar(
                                  content: Text(
                                    'globaolOffset:$globalOffset | originIndex:${widget.relatedIdeas[0].index}  | Lane:${CanvasZoneInheritedWidget.of(context).lanes.indexOf(LaneBodyInheritedWidget.of(context).widget)} Idea:${widget.index}'),
                                ),
                              );
                              mode.state = false;
                            } else {
                              _textFocusNode.unfocus();
                              LaneBodyInheritedWidget.of(context).addNewIdea(widget.index);
                            }
                          },
                        ),
                      ),
                    ),
                  ]),
                ),
              ],
            ),
          ),
        ));
  }
}

class _CustomPainter extends CustomPainter {
  final void Function(Canvas, Size) _paint;
  _CustomPainter(this._paint);

  @override
  void paint(Canvas canvas, Size size) {
    _paint(canvas, size);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

class _CustomPainterB extends CustomPainter {
  final void Function(Canvas, Size) _paint;

  _CustomPainterB(this._paint, Listenable repaint) : super(repaint: repaint);

  @override
  void paint(Canvas canvas, Size size) {
    _paint(canvas, size);
  }

  @override
  bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}

logic_collection.dart (main.dartと同じフォルダに配置)

import 'package:flutter/material.dart';
import 'main.dart';
import 'dart:convert'; //JSON形式のデータを扱う
import 'package:path_provider/path_provider.dart';
import 'dart:io';
import 'dart:math';
import 'package:vector_math/vector_math_64.dart' as vector_math;
import 'package:flutter/rendering.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

//リレーションの情報
StateProvider<bool> relatedIdeaSelectMode = StateProvider((ref) => false);
StateProvider<Idea?> originIdeaForSelectMode = StateProvider((ref) => null);
StateProvider<List<IdeasRelation>> relatedIdeasList = StateProvider((ref) => []);

//スケール
StateProvider<double> currentScale = StateProvider((ref) => 1);

//各種ウィジェットのサイズと表現
StateProvider<double> headerHeight = StateProvider((ref) => 50);
StateProvider<double> eachLaneWidth = StateProvider((ref) => 500);
StateProvider<double> eachIdeaHeight = StateProvider((ref) => 64); //48+8+8
StateProvider<double> lanesBodyCanvasHeight = StateProvider((ref) => 500);
StateProvider<double> relationDotSize = StateProvider((ref) => 6);
StateProvider<double> arrowSize = StateProvider((ref) => 8);

StateProvider<Color> relationColor = StateProvider((ref) => Colors.blue);
StateProvider<bool> relationHoverFlg = StateProvider((ref) => false);

const double ideaLeftSpace = 26;
const double ideaHorizontalMargin = 16;
const double indentWidth = 40;

//タイトル行のスクロール用
StateProvider<double> canvasScrollDeltaX = StateProvider((ref) => 0);
StateProvider<double> canvasScrollDeltaY = StateProvider((ref) => 0);


//2点間の距離を求める関数
double getDistance(double x, double y, double x2, double y2) {
  double distance = sqrt((x2 - x) * (x2 - x) + (y2 - y) * (y2 - y));
  return distance;
}
//2点間の角度を求める関数
double getRadian(double x, double y, double x2, double y2) {
  double radian = atan2(y2 - y,x2 - x);
  return radian;
}

class TrianglePainter extends CustomPainter {
  @override
  void paint(Canvas canvas, Size size) {
    var paint = Paint()..color = Colors.blue;

    //var rect = Rect.fromLTWH(0, 0, size.width, size.height);
    //canvas.drawRect(rect, paint);

    // 三角(塗りつぶし)
    var path = Path();
    path.moveTo(size.width / 2, 0);
    path.lineTo(0,              size.height);
    path.lineTo(size.width,     size.height);
    path.close();
    canvas.drawPath(path, paint);
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) => false;
}

Future<dynamic> relationSelectDialog(BuildContext context, CanvasZoneState state){
  return showDialog(
    context: context,
    builder: (context) {
      return SimpleDialog(
        contentPadding: const EdgeInsets.all(16),
        children: [
          Align(
            alignment: Alignment.centerRight,
            child: Tooltip(
              message: MaterialLocalizations
                  .of(context)
                  .closeButtonTooltip,
              child: GestureDetector(
                onTap: () => Navigator.pop(context),
                child: Icon(Icons.close, size: 30),
              ),
            ),
          ),
          Text('テストダイアログです'),
          Container(
            height: 100,
            child:
              Stack(
                children:buildRelationLine(state, 5,10, 150, 10 ),
              ),
          )
        ],
      );
    },
  );
}

List <Widget> buildRelationLine(CanvasZoneState state, double startX, double startY, double endX, double endY) {
  List <Widget> list = [];
  final double dotSize = state.ref.watch(relationDotSize);

  list.add( //ドットの描画 Origin
    Positioned(
      top:  startY,
      left: startX - (dotSize/2) + state.ref.watch(canvasScrollDeltaX),
      child: MouseRegion(
        onEnter: (_) => state.ref.watch(relationHoverFlg.notifier).state=true,
        onExit: (_) => state.ref.watch(relationHoverFlg.notifier).state=false,
        child: Container(
          width: dotSize,
          height: dotSize,
          decoration: BoxDecoration(
            color: state.ref.watch(relationHoverFlg) ? Colors.lightBlueAccent : Colors.blue,
            shape: BoxShape.circle,
          ),
        ),
      ),
    ),
  );
  list.add( //ドットの描画 Relation
    Positioned(
      top:  endY,
      left: endX - (dotSize/2) + state.ref.watch(canvasScrollDeltaX),
      child: MouseRegion(
        child: Container(
          width: dotSize,
          height: dotSize,
          decoration: BoxDecoration(
            color: state.ref.watch(relationHoverFlg) ? Colors.lightBlueAccent : Colors.blue,
            shape: BoxShape.circle,
          ),
        ),
      ),
    ),
  );
  list.add( //線の描画
    Positioned(
      top:  startY + (dotSize/2) -1/*線の太さ分を調整*/,
      left: startX + state.ref.watch(canvasScrollDeltaX)  - (dotSize/2) + (dotSize/2),
      child: Transform.rotate(
        angle: getRadian(startX + state.ref.watch(canvasScrollDeltaX), startY, endX + state.ref.watch(canvasScrollDeltaX), endY),
        alignment: Alignment.topLeft,
//          origin: Offset( state.ref.watch(relationDotSize)/2 , state.ref.watch(relationDotSize)/2 ),
        child: MouseRegion(
          onEnter: (_) => state.ref.watch(relationHoverFlg.notifier).state=true,
          onExit: (_) => state.ref.watch(relationHoverFlg.notifier).state=false,
          child: Container(
            width: getDistance(startX - (dotSize/2) + state.ref.watch(canvasScrollDeltaX), startY, endX - (dotSize/2) + state.ref.watch(canvasScrollDeltaX), endY),
            height: 2,
            decoration: BoxDecoration(
              color: state.ref.watch(relationHoverFlg) ? Colors.lightBlueAccent : Colors.blue,
            ),
          ),
        ),
      ),
    ),
  );
  return list;
}

List <Widget> buildIdeasAndRelations(CanvasZoneState state) {
  List <Widget> list = [];
  final double laneWidth = state.ref.watch(eachLaneWidth);

  /* Ideas */
  list.add(
    Positioned( // Ideasの描画
      top:  0,
      left: 0 + state.ref.watch(canvasScrollDeltaX),
      width: state.ref.watch(eachLaneWidth) * state.lanes.length,
      height: state.ref.watch(lanesBodyCanvasHeight),
      child: ListView.builder(
        scrollDirection: Axis.horizontal,
        itemCount: state.lanes.length,
        itemBuilder: (context, index) {
          return SizedBox( // Laneごとの箱
            width: state.ref.watch(eachLaneWidth),
            child: state.lanes[index],
          );
        },
      ),
    ),
  );
  if (state.widget.key.currentState == null) return list;
  if (state.widget.key.currentState!.lanes.isEmpty) return list;
  if (state.widget.key.currentState!.lanes[0].ideas.isEmpty) return list;
//  if (state.widget.key.currentState!.lanes[0].key.currentContext == null) return list;

  /* Relation */
  for (var relationSet in state.ref.watch(relatedIdeasList)) {

    // leftを必ずstartIdeaとしてしまう。左右逆のパターンもロジックを記述すると、回転の計算などが面倒になったため
    Idea startIdea;
    Idea destinationIdea;;
    if (relationSet.origin.parentLane!.index <= relationSet.related.parentLane!.index ) {
      startIdea  = relationSet.origin;
      destinationIdea = relationSet.related;
    } else {
      startIdea  = relationSet.related;
      destinationIdea = relationSet.origin;
    }

    // 描画位置から求めると、パン・スクロールしたときにどんどんずれていく
    // RenderBox originIdeaTextFieldBox = origin.textFieldKey.currentContext?.findRenderObject() as RenderBox;
    double startY = state.globalTop - state.ref.watch(headerHeight) + ( state.ref.watch(eachIdeaHeight) * startIdea.index ) + ( state.ref.watch(eachIdeaHeight) / 2 ) -  (state.ref.watch(relationDotSize));
    double startX = (startIdea.parentLane!.index + 1) * state.ref.watch(eachLaneWidth) - ideaHorizontalMargin;

    /* Related */
    double endY = state.globalTop - state.ref.watch(headerHeight) + ( state.ref.watch(eachIdeaHeight) * destinationIdea.index ) + ( state.ref.watch(eachIdeaHeight) / 2 ) -  (state.ref.watch(relationDotSize));
    double endX = laneWidth * (destinationIdea.parentLane!.index) + ideaHorizontalMargin + ideaLeftSpace + (indentWidth * destinationIdea.indentLevel);

    if (startIdea.parentLane!.index < destinationIdea.parentLane!.index && destinationIdea.indentLevel >= 1) { //Relatedが右側にあり、インデントされている場合
      //relatedIdeaY += 5; //Related側のインデントの線と重なるのを避けるために少しずらす
    }


    list.addAll(buildRelationLine(state,startX,startY,endX,endY));

    list.add( //三角の描画
      Positioned(
        top: 100,
        left: 100,
        child: CustomPaint(
          painter: TrianglePainter(),
          child: Container(
            width: state.ref.watch(arrowSize),
            height: state.ref.watch(arrowSize),
          ),
        ),
      ),
    );
/*
    //座標の表示
    RenderBox originIdeaBox = origin.key.currentContext?.findRenderObject() as RenderBox;
    list.add(
      Positioned(
        top:  originIdeaY - 10,
        left: originTextFieldX - (state.ref.watch(relationDotSize)/2) + state.ref.watch(canvasScrollDeltaX) + 10,
        child: Text(
            "globalTop:${state.globalTop} globalTop+headerHeight:${state.globalTop+ state.ref.watch(headerHeight)} state.widget.key.currentContext?.findRenderObject() as RenderBox).localToGlobal(Offset.zero).dy${(state.widget.key.currentContext?.findRenderObject() as RenderBox).localToGlobal(Offset.zero).dy}\n"+
            "originIdeaBox.localToGlobal(Offset.zero).dy:${originIdeaBox.localToGlobal(Offset.zero).dy.toInt()} originIdeaY:${originIdeaY.toInt()} originIdeaBox.GlobalToLocal(Offset.zero).dy:${originIdeaBox.globalToLocal(Offset.zero).dy.toInt()} ",
            style: TextStyle(color: Colors.blue),),
      ),
    );
*/

  }
  return list;
}

class relationWidgets extends ConsumerStatefulWidget {
  const relationWidgets({Key? key}) : super(key: key);

  @override
  _relationWidgetsState createState() => _relationWidgetsState();
}

class _relationWidgetsState extends ConsumerState<relationWidgets> {
  @override
  Widget build(BuildContext context) {
    for (var relationSet in ref.watch(relatedIdeasList)) {
      Idea origin = relationSet.origin;
      Idea related = relationSet.related;
      print("[_relationWidgetsState] origin lane:${origin.parentLane?.index},idea:${origin.index} , related lane:${related.parentLane?.index},idea:${related.index}");

      RenderBox originIdeaBox = origin.key.currentContext?.findRenderObject() as RenderBox;
      RenderBox originIdeaTextFieldBox = origin.textFieldKey.currentContext?.findRenderObject() as RenderBox;
      RenderBox relatedIdeaBox = related.key.currentContext?.findRenderObject() as RenderBox;
      RenderBox relatedIdeaTextFieldBox = related.textFieldKey.currentContext?.findRenderObject() as RenderBox;

      double originIdeaY = originIdeaBox.localToGlobal(Offset.zero).dy + (originIdeaBox.size.height / 2 );

      double originTextFieldX = originIdeaTextFieldBox.localToGlobal(Offset.zero).dx;
      if (origin.parentLane!.index < related.parentLane!.index) { //Originが左側なら、Originの右側を起点とする
        originTextFieldX += originIdeaTextFieldBox.size.width;
      }
    };

    return Positioned(
      top: 200,
      left: 300,
      child: Container(
        width: 8,
        height: 8,
        decoration: BoxDecoration(
          color: Colors.blue,
          shape: BoxShape.circle,
        ),
      ),
    );
  }
}

Offset getWidgetGlobalPositionInInteractiveViewer(GlobalKey widgetKey, TransformationController transformationController, GlobalKey interactiveViewerKey) {

  // 変換行列を取得
  final matrix = transformationController.value;
  // ウィジェットのRenderBoxを取得
  final renderBox = widgetKey.currentContext?.findRenderObject() as RenderBox;
  // ウィジェットのローカル座標を取得
  final localOffset = renderBox.localToGlobal(Offset.zero);
  // ウィジェットのグローバル座標を計算
  final globalOffset = MatrixUtils.transformPoint(Matrix4.inverted(transformationController.value),localOffset);
  return globalOffset;
}

Offset matrix4_transform_point(Matrix4 matrix, Offset point) {
  final vector_math.Vector3 transformed = matrix * vector_math.Vector3(point.dx, point.dy, 0);
  return Offset(transformed.x, transformed.y);
}

//関連付けの線を描画する
void relationLinePaint(Canvas canvas,TransformationController transformationController,CanvasZone widget, GlobalKey interactiveViewerKey) {
  final paint = Paint()
    ..color = Colors.blue
    ..strokeWidth = 2;

  if (widget.key.currentState == null) return;
  if (widget.key.currentState!.lanes.isEmpty) return;
  if (widget.key.currentState!.lanes[0].ideas.isEmpty) return;
  if (widget.key.currentState!.lanes[0].key.currentContext == null) return;

  if (widget.key.currentState!.ref.watch(relatedIdeasList).isNotEmpty) {
    for (var relationSet in widget.key.currentState!.ref.watch(relatedIdeasList)) {
      Idea origin = relationSet.origin;
      Idea related = relationSet.related;
      print("[relationLinePaint] origin lane:${origin.parentLane?.index},idea:${origin.index} , related lane:${related.parentLane?.index},idea:${related.index}");

//      RenderBox lane0Box = widget.key.currentState!.lanes[0].key.currentContext?.findRenderObject() as RenderBox;
      RenderBox originIdeaBox = origin.key.currentContext?.findRenderObject() as RenderBox;
      RenderBox originIdeaTextFieldBox = origin.textFieldKey.currentContext?.findRenderObject() as RenderBox;
      RenderBox relatedIdeaBox = related.key.currentContext?.findRenderObject() as RenderBox;
      RenderBox relatedIdeaTextFieldBox = related.textFieldKey.currentContext?.findRenderObject() as RenderBox;

/*
      final paint1 = Paint()
        ..color = Colors.blue;
      canvas.drawCircle(getWidgetGlobalPositionInInteractiveViewer(origin.textFieldKey, transformationController, interactiveViewerKey), 5, paint1);
*/

//      double originIdeaY = originIdeaBox.localToGlobal(Offset.zero).dy - lane0Box.localToGlobal(Offset.zero).dy + (originIdeaBox.size.height / 2 );
      double originIdeaY = originIdeaBox.localToGlobal(Offset.zero).dy + (originIdeaBox.size.height / 2 );

      double originTextFieldX = originIdeaTextFieldBox.localToGlobal(Offset.zero).dx;
      if (origin.parentLane!.index < related.parentLane!.index) { //Originが左側なら、Originの右側を起点とする
        originTextFieldX += originIdeaTextFieldBox.size.width;
      }

//      canvas.drawCircle(Offset(originTextFieldX, originIdeaY) , 3, paint);
      canvas.drawCircle(matrix4_transform_point(Matrix4.inverted(transformationController.value),Offset(originTextFieldX, originIdeaY)) , 3, paint);


/*
      final paint2 = Paint()
        ..color = Colors.orange;
      Offset a = MatrixUtils.transformPoint(Matrix4.inverted(transformationController.value),Offset(originTextFieldX, originIdeaY));
      canvas.drawCircle(a , 3, paint2);

      final paint3 = Paint()
        ..color = Colors.purpleAccent;
      Offset b = matrix4_transform_point(Matrix4.inverted(transformationController.value),Offset(originTextFieldX, originIdeaY));
      canvas.drawCircle(b , 5, paint3);
*/


//      double relatedIdeaY = relatedIdeaBox.localToGlobal(Offset.zero).dy - lane0Box.localToGlobal(Offset.zero).dy + (relatedIdeaBox.size.height / 2 );
      double relatedIdeaY = relatedIdeaBox.localToGlobal(Offset.zero).dy - (relatedIdeaBox.size.height / 2 );
      double relatedTextFieldX = relatedIdeaTextFieldBox.localToGlobal(Offset.zero).dx;
      if (origin.parentLane!.index > related.parentLane!.index) { //Originが右側なら、Relatedの右側を起点とする
        relatedTextFieldX += relatedIdeaTextFieldBox.size.width;
      }
      if (origin.parentLane!.index < related.parentLane!.index && related.indentLevel >= 1) { //Relatedが右側にあり、インデントされている場合
        relatedIdeaY += 5; //Related側のインデントの線と重なるのを避けるために少しずらす
      }

//      canvas.drawCircle(Offset(relatedTextFieldX, relatedIdeaY) , 3, paint);
//      canvas.drawLine(Offset(originTextFieldX, originIdeaY), Offset(relatedTextFieldX, relatedIdeaY), paint);
      canvas.drawCircle(matrix4_transform_point(Matrix4.inverted(transformationController.value),Offset(relatedTextFieldX, relatedIdeaY)) , 3, paint);
      canvas.drawLine(matrix4_transform_point(Matrix4.inverted(transformationController.value),Offset(originTextFieldX, originIdeaY)), matrix4_transform_point(Matrix4.inverted(transformationController.value),Offset(relatedTextFieldX, relatedIdeaY)), paint);

    }
  }
}

void relationLinePaintForDebug(Canvas canvas, CanvasZone widget) {
  //ツールバーの下:Offset(0,0) 又は canvasZone..RenderObject()のTop
  //lanes[0]..RenderObject().localToGlobalのTopは、なぜかIdeasのTopの位置あたりになってしまう(タイトルより下)
  //lanes[0].ideas[0]..RenderObject().localToGlobalのTopは、中途半端に2つめのIdeasのTopのあたりになってしまう
  //とりあえずtitle分下にずらし、idea[0]のTopの位置を求めるには下記で行けそう、
  //allIdeaTopY = idea0box.localToGlobal(Offset.zero).dy - lane0box.localToGlobal(Offset.zero).dy;

  final paint = Paint()
    ..color = Colors.black;
  canvas.drawCircle(const Offset(10,0), 5, paint);

  paint.color = Colors.purple;
  canvas.drawCircle( (widget.key.currentContext?.findRenderObject() as RenderBox).localToGlobal(Offset.zero), 5, paint);

  if (widget.key.currentState == null) return;
  if (widget.key.currentState!.lanes.isEmpty) return;
  if (widget.key.currentState!.lanes[0].ideas.isEmpty) return;

  RenderBox lane0Box = widget.key.currentState!.lanes[0].key.currentContext?.findRenderObject() as RenderBox;
  RenderBox idea0Box = widget.key.currentState!.lanes[0].ideas[0].key.currentContext?.findRenderObject() as RenderBox;
  RenderBox idea0TextFieldBox = widget.key.currentState!.lanes[0].ideas[0].textFieldKey.currentContext?.findRenderObject() as RenderBox;

  paint.color = Colors.red;
  canvas.drawCircle(lane0Box.localToGlobal(Offset.zero), 5, paint);

  paint.color = Colors.green;
  canvas.drawCircle(idea0Box.localToGlobal(Offset.zero), 10, paint);

  paint.color = Colors.yellow;
  double allIdeaTopY = idea0Box.localToGlobal(Offset.zero).dy - lane0Box.localToGlobal(Offset.zero).dy;
  double textFieldLeftX = idea0TextFieldBox.localToGlobal(Offset.zero).dx;
  canvas.drawCircle(Offset(textFieldLeftX + idea0TextFieldBox.size.width, allIdeaTopY + (idea0Box.size.height / 2 )) , 5, paint);
}

void indentLinePaint(Canvas canvas,Size size,BuildContext context, int index, int indentLevel, double textFieldHeight, double boxSpacing, double indentWidth) {
  // インデントの線を描画する
  final paint = Paint()
    ..color = Colors.grey
    ..strokeWidth = 1;
  var leftX = indentWidth / 2;

  // 一行上のIdeaのindentLevelを取得する
  int upperIndentLevel = 0;
  int upperIndex = index - 1;

  //描画:Canvasの起点は左側センター
  if (indentLevel > 0 && index > 0 /*一番上の時はツリーを描画しない*/) {
    //一番上の親が居ない場合の特別対応:自分の上のどこかに、indentLevelが小さい親がいるかチェック
    bool cancelFlg = false; //描画しない特別な場合用
    var i = index - 1;
    while (i >= 0) {
      if ((LaneBodyInheritedWidget.of(context)
          .ideas[i].indentLevel) <
          indentLevel) {
        break;
      }
      i--;
    }
    if (i == -1) cancelFlg = true;

    if (!cancelFlg) {
      //縦の線:左側の基準
      leftX = leftX + (indentWidth * (indentLevel - 1));

      //縦の線:自分の領域内で上に引く
      double startY =
          0 - (textFieldHeight / 2) - (boxSpacing * 2);

      //縦の線:自分の領域外でどのくらい上まで引くかを計算
      upperIndentLevel = LaneBodyInheritedWidget.of(context)
          .ideas[upperIndex].indentLevel;
      if (indentLevel <= upperIndentLevel) {
        while (indentLevel < upperIndentLevel && upperIndex >= 0) {
          startY -= (textFieldHeight + boxSpacing * 2);
          upperIndex--;
          upperIndentLevel = LaneBodyInheritedWidget.of(context)
              .ideas[upperIndex].indentLevel;
        }
        if (indentLevel <= upperIndentLevel) {
          startY -= textFieldHeight / 2;
        }
      }

      //縦の線
      canvas.drawLine(Offset(leftX, 0), Offset(leftX, startY), paint);

      //横の線
      canvas.drawLine(Offset(leftX, size.height),
          Offset(indentWidth * indentLevel, size.height), paint);
    }
  }
}

void findAndRepaintAffectedIdeas(BuildContext context, int myIndex, int myIndentLevel, int startIndex, int endIndex){
  // Find the start index
  for (int i = myIndex - 1; i >= 0; i--) {
    if (LaneBodyInheritedWidget.of(context)
        .ideas[i].indentLevel == myIndentLevel - 1) {
      startIndex = i;
      break;
    }
  }
  // Find the end index
  for (int i = myIndex + 1;
  i < LaneBodyInheritedWidget.of(context).ideas.length;
  i++) {
    if ((LaneBodyInheritedWidget.of(context)
        .ideas[i].indentLevel) >= myIndentLevel) {
      endIndex = i;
      break;
    }
  }
  LaneBodyInheritedWidget.of(context).redrawAffectedIdeas(startIndex, endIndex);
  CanvasZoneInheritedWidget.of(context).counterForReaint.value++;
  CanvasZoneInheritedWidget.of(context).setState(() {}); //Relationの線を描画
}

Future<String> get _defaultFileName async {
  final directory = await getApplicationDocumentsDirectory();
  return '${directory.path}/default.json';
}

Future<void> saveData({String? fileName, required CanvasZoneState? state}) async {
  List<Map<String, dynamic>> lanesJson = [];

  fileName ??= await _defaultFileName;

  if(state == null) return;

  for (var lane in state.lanes ) {
    List<Map<String, dynamic>> ideasJson = [];
    for (var idea in lane.ideas) {
      ideasJson.add({
        'index': idea.index,
        'indentLevel': idea.indentLevel,
        'content': idea.key.currentState?.textController.text ?? '',
      });
    }
    lanesJson.add({
      'key': lane.key.toString(),
      'index': lane.index,
      'title': state.laneHeaders[lane.index].key.currentState?.titleController.text ?? '',
      'ideas': ideasJson,
    });
  }

  List<Map<String, dynamic>> relatedIdeasJson = [];
  state.ref.read(relatedIdeasList).forEach((relationSet) {
    relatedIdeasJson.add({
      'origin_lane_index': relationSet.origin.parentLane?.index,
      'origin_idea_key': relationSet.origin.key.toString(),
      'origin_idea_index': relationSet.origin.index,
      'related_lane_index': relationSet.related.parentLane?.index,
      'related_idea_key': relationSet.related.key.toString(),
      'related_idea_index': relationSet.related.index,
    });
  });

  String jsonString = jsonEncode({'lanes': lanesJson, 'relatedIdeas': relatedIdeasJson});
  await File(fileName).writeAsString(jsonString);
}

Future<void> loadData({String? fileName, required CanvasZoneState? state}) async {
  fileName ??= await _defaultFileName;
  final file = File(fileName);
  if (await file.exists() == false) {
    print('No such files:{$fileName}');
    return;
  }
  String jsonString = await file.readAsString();
  Map<String, dynamic> jsonMap = jsonDecode(jsonString);

  if(state == null) return;

  List<LaneBody> loadedLanes = [];
  List<LaneHeader> loadedLaneHeaders = [];
  for (var laneJson in jsonMap['lanes']) {
    List<Idea> loadedIdeas = [];

    var newLane = LaneBody(
      index: laneJson['index'] ?? 0,
      title: laneJson['title'] ?? '',
      ideas: loadedIdeas,
    );
    var newLaneHeader = LaneHeader(index: laneJson['index'] ?? 0, relatedLane: newLane);
    newLaneHeader.title = laneJson['title'] ?? '';
    loadedLaneHeaders.add(newLaneHeader);

    for (var ideaJson in laneJson['ideas']) {
      loadedIdeas.add(Idea(
        index: ideaJson['index'],
        indentLevel: ideaJson['indentLevel'],
        parentLane: newLane,
      ));
    }
    loadedLanes.add(newLane);
  }
  for (int i = 0; i < loadedLanes.length; i++) {
    for (int j = 0; j < loadedLanes[i].ideas.length; j++) {
      loadedLanes[i].ideas[j].content = jsonMap['lanes'][i]['ideas'][j]['content'];
    }
  }
  state.lanes = loadedLanes;
  state.laneHeaders = loadedLaneHeaders;
  state.ref.watch(relatedIdeasList.notifier).state.clear();

  if (jsonMap['relatedIdeas'] != null) {
    for (var relatedJson in jsonMap['relatedIdeas']) {
      var originLaneIndex = relatedJson['origin_lane_index'];
      var originIdeaIndex = relatedJson['origin_idea_index'];
      var relatedLaneIndex = relatedJson['related_lane_index'];
      var relatedIdeaIndex = relatedJson['related_idea_index'];
      IdeasRelation relationSet = IdeasRelation(
          origin: loadedLanes[originLaneIndex].ideas[originIdeaIndex], related: loadedLanes[relatedLaneIndex].ideas[relatedIdeaIndex]);
      state.ref.watch(relatedIdeasList.notifier).state.add(relationSet);
      print("[loadData] origin lane:$originLaneIndex,idea:$originIdeaIndex, related lane:$relatedLaneIndex,idea:$relatedIdeaIndex");
      loadedLanes[originLaneIndex].ideas[originIdeaIndex].relatedIdeas.add(loadedLanes[relatedLaneIndex].ideas[relatedIdeaIndex]);
    }
  }
}

pubspec.yamlは昨日と変更なし


※本記事は過去の記録をもとに作成し、必要に応じて加筆・補足しています

テキストベースの思考整理ツール「アイディア・レーン」最新版はこちら

コメント

タイトルとURLをコピーしました