縦と横のスクロールバー表示 – 開発日記(19)

2023/04/30

2方向にスクロールできるスクロールバーを表示するため、試行錯誤。

  • シンプルなプログラムでテストした結果 ScrollbarとSingleChildScrollViewをダブルでネストする形に。

scroll_test_doublescroll(調査に使ったプログラム):main.dart ※実際に動かす場合はこれまでのプロジェクトとは別にプロジェクトを作成するか、DartPadを利用してください

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const MyHomePage(title: 'Flutter Demo Home Page'),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  double _counter = 200;
  final _scrollControllerVertical = ScrollController(initialScrollOffset: 0.0);
  final _scrollControllerHorizontal = ScrollController(initialScrollOffset: 0.0);

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        // Here we take the value from the MyHomePage object that was created by
        // the App.build method, and use it to set our appbar title.
        title: Text(widget.title),
      ),
      body: Align(
        alignment: Alignment.topLeft,
        child: Container(
          width: MediaQuery.of(context).size.width,
          height: MediaQuery.of(context).size.height,
          color:  Colors.green,
          child: Stack(
            children:[
              Positioned(
                left: _counter,
                top: 10,
                child: Container(
                  color: Colors.white,
                  width: 600,
                  height: 80,
                )
              ),
              Positioned(
                left: 200,
                top: 100,
                child: Container(
                  width: 600,
                  height: 600,
                  color: Colors.red[200],
                  child: Scrollbar(
                    controller: _scrollControllerVertical,
                    thumbVisibility: true,
                    trackVisibility: false,
                    child: Scrollbar(
                      controller: _scrollControllerHorizontal,
                      thumbVisibility: true,
                      trackVisibility: true,
                      notificationPredicate: (notif) => notif.depth == 1,
                      child: SingleChildScrollView(
                        controller: _scrollControllerVertical,
                        child: SingleChildScrollView(
                          controller: _scrollControllerHorizontal,
                          scrollDirection: Axis.horizontal,
                          child: GestureDetector(
                            onPanUpdate: (details) {
                              _scrollControllerVertical.jumpTo(_scrollControllerVertical.position.pixels - details.delta.dy);
                              _scrollControllerHorizontal.jumpTo(_scrollControllerHorizontal.position.pixels - details.delta.dx);
                              print("Vertical.position:${_scrollControllerVertical.position.pixels} Vertical.maxExtent:${_scrollControllerVertical.position.maxScrollExtent}");
                              setState(() {
                                _counter += details.delta.dx;
                              });
                            },
                            child: Container(
                              width: 1200,
                              height: 1200,
                              color: Colors.blue[200],
                              child: Center(
                                child: Container(
                                  width: 600,
                                  height: 600,
                                  color: Colors.yellow,
                                ),
                              )
                            ),
                          ),
                        ),
                      ),
                    ),
                  ),
                ),
              ),
            ]
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}

2023/04/30

  • ここまでスクロール系は非常に苦労したが、なんとか2つのスクロールバーが出てかつ制御できるようになってきた。
    • 特に困ったのがマウスをまっすぐ上にドラッグ開始してしまうと、そのまま斜めにパン出来なくなる症状。
    • 原因はどこかで以前宣言していた「MyCustomScrollBehavior」という関数のせいだった。。それを外したらスムースに動くようになった。
  • これでスクロール&パンの部分はほぼ形になってきた。

この時点でのflutterソースファイル:main.dart

import 'package:flutter/foundation.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';
import 'package:cross_scroll/cross_scroll.dart';

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

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

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      //scrollBehavior: MyCustomScrollBehavior(),
      home: const Scaffold(
        body: SafeArea(child:
          WholeApp(),
        ),
      ),
    );
  }
}

class WholeApp extends StatefulWidget {
  const WholeApp({Key? key}) : super(key: key);

  @override
  State<WholeApp> createState() => _WholeAppState();
}

class _WholeAppState extends State<WholeApp> {
  GlobalKey<CanvasZoneState> canvasZoneKey = GlobalKey();
  GlobalKey<ToolbarZoneState> toolbarZoneKey = GlobalKey();
  late ToolbarZone toolbarZone;
  late CanvasZone canvasZone;
  double x = 0.0;
  double y = 0.0;

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

  void _updateMouseLocation(PointerEvent details) {
    setState(() {
      toolbarZoneKey.currentState!.mouseX = details.position.dx;
      toolbarZoneKey.currentState!.mouseY = details.position.dy;
    });
  }

  @override
  Widget build(BuildContext context) {
    return WholeAppInheritedWidget(
      wholeAppState: this,
      child: MouseRegion(
        onHover: _updateMouseLocation, //座標を表示する
        child: Column(
          children: [
            toolbarZone,
            const Divider(),
            canvasZone,
          ]
        ),
      ),
    );
  }
}

class WholeAppInheritedWidget extends InheritedWidget {
  final _WholeAppState wholeAppState;
  const WholeAppInheritedWidget({required this.wholeAppState, required Widget child, super.key}) : super(child: child);

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

  static _WholeAppState of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<WholeAppInheritedWidget>()!.wholeAppState;
  }
}

class ToolbarZone extends ConsumerStatefulWidget {
  @override
  final GlobalKey<ToolbarZoneState> key;

  const ToolbarZone({required this.key}) : super(key: key);

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

class ToolbarZoneState extends ConsumerState<ToolbarZone> {
  double mouseX = -1;
  double mouseY = -1;

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

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

  void scaleUp() {
/*
    if (WholeAppInheritedWidget.of(context).canvasZoneKey.currentState == null) return;
    TransformationController controller = WholeAppInheritedWidget.of(context).canvasZoneKey.currentState!._transformationController;
    controller.value = Matrix4.identity()..scale(controller.value.getMaxScaleOnAxis() * 1.2);
    ref.watch(currentScale.notifier).state = controller.value.getMaxScaleOnAxis();
*/
    ref.watch(currentScale.notifier).state = ref.read(currentScale)*1.2;
  }

  void scaleDown() {
/*
    if (canvasZoneKey.currentState == null) return;
    TransformationController controller = canvasZoneKey.currentState!._transformationController;
    controller.value = Matrix4.identity()..scale(controller.value.getMaxScaleOnAxis() * 0.85);
    ref.watch(currentScale.notifier).state = controller.value.getMaxScaleOnAxis();
    print("${MediaQuery.of(context).size.height * (1 / ref.watch(currentScale))}");
*/
    ref.watch(currentScale.notifier).state = ref.read(currentScale)*0.85;
  }

  void scaleReset() {
    if (WholeAppInheritedWidget.of(context).canvasZoneKey.currentState == null) return;
    TransformationController controller = WholeAppInheritedWidget.of(context).canvasZoneKey.currentState!._transformationController;
    controller.value = Matrix4.identity();
    ref.watch(currentScale.notifier).state = 1.0;
    ref.watch(canvasScrollDeltaX.notifier).state = 0;
    ref.watch(canvasScrollDeltaY.notifier).state = 0;
  }

  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: WholeAppInheritedWidget.of(context).canvasZoneKey.currentState);
      //レーンの高さを取得
      ref.watch(lanesBodyCanvasHeight.notifier).state = WholeAppInheritedWidget.of(context).canvasZoneKey.currentState!.getMaxLanesHight();
      //スクロールポジションを戻す
      //WholeAppInheritedWidget.of(context).canvasZoneKey.currentState!._scrollController.jumpTo(0);
      WholeAppInheritedWidget.of(context).canvasZoneKey.currentState!.setState(() {}); //画面の再描画
    }
  }

  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: WholeAppInheritedWidget.of(context).canvasZoneKey.currentState);
    }
  }

  String getCurrentMousePositionStr() {
    String result = "";
    if (mouseX != -1) {
      result = "x:${mouseX.toInt().toString().padLeft(5,'0')}, y:${mouseY.toInt().toString().padLeft(5,'0')} ";
    }
    return result;
  }

  @override
  Widget build(BuildContext context) {
    return ToolbarZoneInheritedWidget(
      toolbarZoneState: this,
      child: Column(children: [
        Align(
          alignment: Alignment.center,
          child: SingleChildScrollView(
            scrollDirection: Axis.horizontal,
            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),
                  Text(getCurrentMousePositionStr()),
                ],
              ),
            ),
          ),
        ),
      ]),
    );
  }
}


class ToolbarZoneInheritedWidget extends InheritedWidget {
  final ToolbarZoneState toolbarZoneState;
  const ToolbarZoneInheritedWidget({required this.toolbarZoneState, required Widget child, super.key}) : super(child: child);

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

  static ToolbarZoneState of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<ToolbarZoneInheritedWidget>()!.toolbarZoneState;
  }
}

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

  const CanvasZone({required 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 TransformationController _transformationController = TransformationController();
  final GlobalKey _interactiveViewerKey = GlobalKey();
  final GlobalKey _box1Key = GlobalKey();
  final GlobalKey _box2Key = GlobalKey();
  double globalTop = 0.0;
  final _scrollControllerVertical = ScrollController();
  final _scrollControllerHorizontal = ScrollController();
//  double _scale = 1.0;
  double dx=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;
    for (var lane in lanes) {
      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> 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);
    });
  }

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

  Future<void> addNewLaneAndFocusIt() async {
    await addNewLane();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      lanes[lanes.length - 1].ideas[0].key.currentState?._textFocusNode.requestFocus();
    });
  }

  void removeLane() {
    setState(() {
      if (lanes.length > 1) {
        //関係するリレーションを削除
        for (var idea in lanes[lanes.length - 1].ideas) {
          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( checkAndFillIdeas(targetLaneIndex, targetIdeaIndex) ) {
      WidgetsBinding.instance.addPostFrameCallback((_) {
        lanes[targetLaneIndex].ideas[targetIdeaIndex].key.currentState?._textFocusNode.requestFocus(); //FillIdeasされた場合は遅延でのフォーカスが必要
      });
    } else {
      lanes[targetLaneIndex].ideas[targetIdeaIndex].key.currentState?._textFocusNode.requestFocus(); //FillIdeasされなかった場合は即座にフォーカス
    }
  }

  bool checkAndFillIdeas(int targetLaneIndex, int targetIdeaIndex) {
    bool added = false; //アイディアかレーンが追加されたかどうかで、呼び出し元が処理を分けられるようにする
    if (targetLaneIndex < 0 || targetIdeaIndex < 0) return added;
    if (targetLaneIndex == lanes.length) {
      addNewLane();
      added = true;
    }
    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);
        }
        added = true;
      }
    }
    return added;
  }

  @override
  Widget build(BuildContext context) {
    return CanvasZoneInheritedWidget(
      canvasZoneState: this,
      child: Expanded( //これが無いと画面描画のOverFlowを起こす
        child: Stack(
          children:[
            Positioned(
              top: 0,
              left:0,
              child: Transform.scale(
                alignment: Alignment.topLeft,
                scale: ref.watch(currentScale),
                child: Container( //Canvasの全体サイズ。但し拡大縮小した場合にはサイズを補正する
                  width:  max( MediaQuery.of(context).size.width * (1 / ref.watch(currentScale)), ref.watch(eachLaneWidth) * lanes.length ),
                  height: max((MediaQuery.of(context).size.height - globalTop) * (1 / ref.watch(currentScale)),  ( ref.watch(headerHeight) + ref.watch(lanesBodyCanvasHeight) ) * (1 / ref.watch(currentScale)) ),
                  color: Colors.transparent, // カラーを指定しないと上のGestureDetectorの範囲がchildの範囲に収まってしまう
                  child: Stack(
                    children: [
                      Positioned( // タイトル行
                        top:0,
                        left:0,
                        child: Container( //タイトル行のビューポート
                          width: max( ref.watch(eachLaneWidth) * lanes.length, MediaQuery.of(context).size.width * (1 / ref.watch(currentScale)) ),
                          height: ref.watch(headerHeight),
                          child: Stack(
                            children: [
                              Positioned(
                                top:   0,
                                left:  ref.watch(canvasScrollDeltaX),
                                width: ref.watch(eachLaneWidth) * lanes.length,
                                height:ref.watch(headerHeight),
                                child: Row( children: laneHeaders, ),
                              ),
                            ],
                          ),
                        ),
                      ),
                      Positioned( // ボディ部
                        top: ref.watch(headerHeight),
                        left:0,
                        child: GestureDetector(
                          onPanUpdate: (details) {
                            _scrollControllerVertical.jumpTo(_scrollControllerVertical.position.pixels - details.delta.dy);
                            _scrollControllerHorizontal.jumpTo(_scrollControllerHorizontal.position.pixels - details.delta.dx);

                            //タイトル行を一緒に動かす
                            double limitX = (ref.watch(eachLaneWidth) * lanes.length)* ref.watch(currentScale)  - MediaQuery.of(context).size.width;
                            limitX = limitX * (1/ ref.watch(currentScale));// + (ref.watch(eachLaneWidth)*1/5); //スケールが拡大するごとにずれていくので調整+スクロール用バッファ
                            if(limitX<0) {
                              ref.watch(canvasScrollDeltaX.notifier).state = 0;
                            } else {
                              ref.watch(canvasScrollDeltaX.notifier).state += details.delta.dx;
                              ref.watch(canvasScrollDeltaX.notifier).state = ref.watch(canvasScrollDeltaX).clamp( -limitX, 0 );
                            }

                            print("------");
                            print("details.delta.dx:${details.delta.dx.toInt()}");
                            print("lanesBodyCanvasHeight*currentScale:${(ref.watch(lanesBodyCanvasHeight) * ref.watch(currentScale)).toInt()} (${ref.watch(lanesBodyCanvasHeight)}) * (${ref.watch(currentScale)})");
                            print("MediaQuery.of(context).size.height - globalTop - ( ref.watch(headerHeight)) * ref.watch(currentScale) :${ MediaQuery.of(context).size.height - globalTop - ( ref.watch(headerHeight)) * ref.watch(currentScale) }");
                            print("MediaQuery.of(context).size.height:${MediaQuery.of(context).size.height} globalTop:${globalTop} headerHeight:${ref.watch(headerHeight)}");
                            print("ref.watch(lanesBodyCanvasHeight):${ref.watch(lanesBodyCanvasHeight)}");
                            print("Vertical.position:${_scrollControllerVertical.position.pixels.toInt()} Vertical.maxExtent:${_scrollControllerVertical.position.maxScrollExtent.toInt()}");
                            print("-");
                            print("Horizontal.position:${_scrollControllerHorizontal.position.pixels.toInt()} Horizontal.maxExtent:${_scrollControllerHorizontal.position.maxScrollExtent.toInt()}");
                            print("MediaQuery.of(context).size.width:${MediaQuery.of(context).size.width}");
                            print("box1.width:${_box1Key.currentContext!.size!.width.toInt()} box2.width:${_box2Key.currentContext!.size!.width.toInt()}");
                            print("ref.watch(eachLaneWidth):${ref.watch(eachLaneWidth)} lanes.length:${lanes.length}");
                          },
                          onTap: () async {
                            //print("${_scrollController2.position.maxScrollExtent} ${_scrollController2.position.pixels}");
                            if (ref.watch(relationHoverFlg)) {
                              await relationSelectDialog(context,this);
                              setState(() {});
                            }
                          },
                          child: Container( //スクロール範囲のビューポート
                            key: _box1Key,
                            width: MediaQuery.of(context).size.width  * (1 / ref.watch(currentScale)),
                            height: (MediaQuery.of(context).size.height - globalTop - ( (ref.watch(headerHeight)*ref.watch(currentScale)) ) ) * (1/ref.watch(currentScale)),
                            child: Scrollbar(
                              controller: _scrollControllerVertical,
                              thumbVisibility: true,
                              trackVisibility: false,
                              child: Scrollbar(
                                controller: _scrollControllerHorizontal,
                                thumbVisibility: true,
                                trackVisibility: true,
                                notificationPredicate: (notif) => notif.depth == 1,
                                child: SingleChildScrollView(
                                  controller: _scrollControllerVertical,
                                  child: SingleChildScrollView(
                                    controller: _scrollControllerHorizontal,
                                    scrollDirection: Axis.horizontal,
                                    child: SizedBox( // 子に描画用のStackを配置するために土台が必要
                                      key: _box2Key,
                                      width: ref.watch(eachLaneWidth) * lanes.length,
                                      //width:  max( ref.watch(eachLaneWidth) * lanes.length, MediaQuery.of(context).size.width * (1 / ref.watch(currentScale)) ) + 30,/*バッファ*/
                                      //height: max( ref.watch(lanesBodyCanvasHeight) + ref.watch(eachIdeaHeight)/*バッファ*/,  MediaQuery.of(context).size.height * (1 / ref.watch(currentScale)) ),
                                      height: ref.watch(lanesBodyCanvasHeight) + (ref.watch(eachIdeaHeight) * 2/3)/*バッファ*/,
                                      child:Stack(
                                        children:
                                          buildIdeasAndRelations(this),
                                      ),
                                    ),
                                  ),
                                ),
                              ),
                            ),
                          ),
                        ),
                      ),
                    ]
                  ),
                ),
              ),
            ),
          ]
        ),
      ),
    );
  }
}

class CanvasZoneInheritedWidget extends InheritedWidget {
  final CanvasZoneState canvasZoneState;
  const CanvasZoneInheritedWidget({required this.canvasZoneState, required Widget child, super.key}) : super(child: child);

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

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

class IdeasRelation {
  Idea fromIdea;
  Idea toIdea;
  RelationShape fromShape=RelationShape.dot;
  RelationShape toShape=RelationShape.dot;
  String note = '';

  IdeasRelation({required this.fromIdea, required this.toIdea});
}

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
  ConsumerState<LaneHeader> 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).laneHeaders[widget.index - 1].key.currentState?._titleFocusNode.requestFocus();
                }
              }
            }
            return KeyEventResult.ignored;
          },
          child: Container( //タイトル
            color: Colors.blue[50],
            padding: const EdgeInsets.symmetric(horizontal: indentWidth),
            child: TextField(
              autofocus: true,
              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;

  const LaneHeaderInheritedWidget({required this.laneHeaderState, required Widget child, super.key}) : 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
  ConsumerState<LaneBody> createState() => LaneBodyState();
}

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

  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,
        ),
      );
      rebuildItemIndexes(currentIndex + 2);
    });
  }

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

  void rebuildItemIndexes(int startIndex) {
    for (int i = startIndex; i < ideas.length; i++) {
      ideas[i].index = i;
    }
    ref.watch(lanesBodyCanvasHeight.notifier).state = CanvasZoneInheritedWidget.of(context).getMaxLanesHight();
  }

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

  void moveAndInsertIdeaToAnotherLane(int currentIdeaIndex, int direction) {
    if(widget.index + direction < 0) {
      return;
    }
    var canvas = CanvasZoneInheritedWidget.of(context);
    canvas.checkAndFillIdeas( widget.index + direction, max(currentIdeaIndex - 1,0) );
    canvas.lanes[widget.index + direction].ideas.insert(currentIdeaIndex, ideas[currentIdeaIndex]);
    canvas.lanes[widget.index + direction].ideas[currentIdeaIndex].parentLane = canvas.lanes[widget.index + direction];
    canvas.lanes[widget.index + direction].key.currentState!.rebuildItemIndexes(0);

    canvas.lanes[widget.index].ideas.removeAt(currentIdeaIndex);
    rebuildItemIndexes(0);
  }

  void removeFromRelation(Idea targetIdea) {
    //削除対象のリレーションを探す
    List<IdeasRelation> removeTargetList = [];
    if (ref.read(ideasRelationList).isNotEmpty) {
      for (var relationSet in ref.read(ideasRelationList)) {
        if (relationSet.fromIdea == targetIdea || relationSet.toIdea == targetIdea) {
          removeTargetList.add(relationSet);
        }
      }
    }
    //実際に削除する
    if (removeTargetList.isNotEmpty) {
      for (var relationSet in removeTargetList) {
        removeRelation(ref.watch(ideasRelationList.notifier).state, relationSet);
      }
    }
  }

  void removeIdea(int currentIndex) {
    if (ideas.length > 1) {
      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();
      CanvasZoneInheritedWidget.of(context).setState(() {}); //画面の再描画
    }
  }

  void focusNextIdea(int currentIndex, int direction) {
    if (currentIndex == 0 && direction <= 0) { //タイトルにフォーカスを移す
      CanvasZoneInheritedWidget.of(context).laneHeaders[widget.index].key.currentState!._titleFocusNode.requestFocus();
    } 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;

  const LaneBodyInheritedWidget({required this.laneState, required Widget child, super.key}) : 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 textFieldHeight; //テキスト欄の高さ
  String content;
  LaneBody? parentLane;
  List<Idea> relatedIdeasTo = [];
  List<Idea> relatedIdeasFrom = [];

  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, indentWidth);
  }

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

  void startRelationMode() {
    ref.watch(relatedIdeaSelectMode.notifier).state = true;
    ref.watch(originIdeaForSelectMode.notifier).state = widget;
    ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar( content: Text( '接続先を選んでEnterキーを押してください'),)
    );
  }

  void endRelationMode() {
    StateController<bool> mode = ref.watch(relatedIdeaSelectMode.notifier);
    Idea? origin = ref.watch(originIdeaForSelectMode.notifier).state;
    ScaffoldMessenger.of(context).hideCurrentSnackBar();
    if (origin != null) {
      bool cancelFlg = false;
      List<IdeasRelation> ideasRelations = ref.watch(ideasRelationList.notifier).state;

      //自分自身に接続しようとしていないかチェック
      if(origin == widget) {
        cancelFlg = true;
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar( content: Text('自分自身には接続できません'), ),
        );
      } else {
        //既に同じリレーションが無いかチェック
        for (var element in ideasRelations) {
          if ( (element.fromIdea == origin && element.toIdea == widget) || (element.fromIdea == widget && element.toIdea == origin) ){
            cancelFlg = true;
            ScaffoldMessenger.of(context).showSnackBar(
              const SnackBar( content: Text('既に接続されています'), ),
            );
            break;
          }
        }
      }
      if(cancelFlg == false) {
        widget.relatedIdeasFrom.add(origin);
        origin.relatedIdeasTo.add(widget);
        ref.watch(ideasRelationList.notifier).state.add(IdeasRelation(fromIdea: origin, toIdea: widget));
        CanvasZoneInheritedWidget.of(context).setState(() {}); //Relationの線を描画
      }
      origin = null;
    }
    mode.state = false;
  }

  late final Map<Type, Action<Intent>> _actionMap;

  @override
  void initState() {
    super.initState();
    textController.text = widget.content;
    _actionMap = <Type, Action<Intent>>{
      ActivateIntent: CallbackAction<Intent>(
        onInvoke: (Intent intent) => startRelationMode(),
      ),
    };
  }

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

  final Map<ShortcutActivator, Intent> _shortcutMap =
  const <ShortcutActivator, Intent>{
    SingleActivator(LogicalKeyboardKey.keyX, control: true): ActivateIntent(),
  };

  //TextFieldでEnterを押した場合のキーの処理について
  //・webだと、submit→key webの順で処理される
  //・windowsだと、key → submitの順で処理される
  @override
  Widget build(BuildContext context) {
    super.build(context);
    return Focus(
      onKey: (FocusNode node, RawKeyEvent event) {

        //print("event.runtimeType:${event.runtimeType.toString()} Normal${event.logicalKey.keyLabel} , Web${ (event.data is RawKeyEventDataWeb) ? (event.data as RawKeyEventDataWeb).code : ''}");

        if (event.runtimeType == RawKeyDownEvent) {
          if ((event.logicalKey.keyLabel == 'Enter') ||
              ( (event.data is RawKeyEventDataWeb)&&(event.data as RawKeyEventDataWeb).code == 'Enter') ) { //Enterの場合のみ特殊処理が必要
            if (event.isControlPressed) {
              startRelationMode();
            }
            else {
              if (ref.read(relatedIdeaSelectMode.notifier).state == true) { //relatedの指定モード
                endRelationMode();
                _textFocusNode.requestFocus();
              } else {
                _textFocusNode.unfocus();
                LaneBodyInheritedWidget.of(context).addNewIdeaWithFocus(widget.index);
              }
            }
            return KeyEventResult.handled;
          } else if (event.logicalKey == LogicalKeyboardKey.capsLock) {
            // CapsLockキーを無視する条件を追加
            return KeyEventResult.ignored;
          } else if (event.isControlPressed) { // コントロールキー押下
            bool needRepaint = false;
            if (event.logicalKey == LogicalKeyboardKey.arrowUp) { // Ctrl + ↑
              LaneBodyInheritedWidget.of(context).moveIdea(widget.index, -1);
              needRepaint = true;
            } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { // Ctrl + ↓
              if (widget.index + 1 == LaneBodyInheritedWidget.of(context).ideas.length) {
                LaneBodyInheritedWidget.of(context).addNewIdea(widget.index);
                LaneBodyInheritedWidget.of(context).moveIdea(widget.index, 1);
                LaneBodyInheritedWidget.of(context).rebuildItemIndexes(0); //再度構築しなおさないと、インデックスがおかしくなる
              } else {
                LaneBodyInheritedWidget.of(context).moveIdea(widget.index, 1);
              }
              needRepaint = true;
            }
            if (event.logicalKey == LogicalKeyboardKey.arrowRight) { // Ctrl + →
              LaneBodyInheritedWidget.of(context).moveAndInsertIdeaToAnotherLane(widget.index, 1);
              needRepaint = false;
            } else if (event.logicalKey == LogicalKeyboardKey.arrowLeft) { // Ctrl + ←
              LaneBodyInheritedWidget.of(context).moveAndInsertIdeaToAnotherLane(widget.index, -1);
              needRepaint = false;
            } else if (event.logicalKey == LogicalKeyboardKey.tab) {  // Ctrl + TAB
              startRelationMode();
            }
            if (needRepaint) {
              findAndRepaintAffectedIdeas(context, widget.index, widget.indentLevel, widget.index,
                  widget.index + 1 /*Ctrlキーの場合、1つ下からチェックが必要*/);
            }
          } else if (event.logicalKey == LogicalKeyboardKey.arrowUp) { //カーソルキー ↑
            LaneBodyInheritedWidget.of(context).focusNextIdea(widget.index, -1);
            return KeyEventResult.handled; //余計なフォーカスの移動を防ぐ
          } else if (event.logicalKey == LogicalKeyboardKey.arrowDown) { //カーソルキー ↓
            if (widget.index == LaneBodyInheritedWidget.of(context).ideas.length - 1) {
              LaneBodyInheritedWidget.of(context).addNewIdeaWithFocus(widget.index);
            } else {
              LaneBodyInheritedWidget.of(context).focusNextIdea(widget.index, 1);
            }
            return KeyEventResult.handled; //余計なフォーカスの移動を防ぐ
          } 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) { //SHIFT + TABキー
              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) { // F1キー
            startRelationMode();
          }
        }
        return KeyEventResult.ignored;
      },
      child: RawKeyboardListener(
        focusNode: FocusNode(),
        child: Container(
          margin: const EdgeInsets.symmetric(horizontal: ideaHorizontalMargin, vertical: ideaVerticalMargin),
          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: FocusableActionDetector(
                        actions: _actionMap,
                        shortcuts: _shortcutMap,
                        child: TextFormField(
                          controller: textController,
                          focusNode: _textFocusNode,
                          maxLines: 1,
                          decoration: const InputDecoration(
                            border: OutlineInputBorder(),
                          ),
                          keyboardType: TextInputType.multiline,
                          textInputAction: TextInputAction.done,
                          onFieldSubmitted: (value) {
                            _textFocusNode.requestFocus(); //Enterはこれが無いとKeyEventが発生しない(おそらくフォーカスが無くなるから。少なくともWebでは必須)
                          },
                        ),
                      ),
                    ),
                  ),
                ]),
              ),
            ],
          ),
        ),
      )
    );
  }
}

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;
}

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:flutter_riverpod/flutter_riverpod.dart';

//リレーションの情報
StateProvider<bool> relatedIdeaSelectMode = StateProvider((ref) => false);
StateProvider<Idea?> originIdeaForSelectMode = StateProvider((ref) => null);
StateProvider<List<IdeasRelation>> ideasRelationList = StateProvider((ref) => []);
StateProvider<IdeasRelation?> selectedRelation = StateProvider((ref) => null);
//リレーションの見た目
enum RelationShape{dot,arrow}
StateProvider<Color> relationColor = StateProvider((ref) => Colors.blue);
StateProvider<bool> relationHoverFlg = StateProvider((ref) => false);
const bool relationDebugPaint = false;

//スケール
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> relationArrowSize = StateProvider((ref) => 10);

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

const Color relationColorTransparent = Colors.transparent;//デバッグ時に変えられるよう定義

//タイトル行のスクロール用
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 TrianglePainterR extends CustomPainter{
  Color color;
  TrianglePainterR(this.color);

  @override
  void paint(Canvas canvas, Size size) {
    var paint = Paint()..color = color;

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

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

//左向き矢印
class TrianglePainterL extends CustomPainter{
  Color color;
  TrianglePainterL(this.color);

  @override
  void paint(Canvas canvas, Size size) {
    var paint = Paint()..color = color;

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

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

void removeRelation(List<IdeasRelation> ideasRelationList, IdeasRelation targetRelation) {
  targetRelation.fromIdea.relatedIdeasTo.remove(targetRelation.toIdea);
  targetRelation.toIdea.relatedIdeasFrom.remove(targetRelation.fromIdea);
  ideasRelationList.remove(targetRelation);
}

Future<dynamic> relationSelectDialog(BuildContext context, CanvasZoneState state){
  var isSelected = <bool>[false, false, false, false];
  GlobalKey toggleButtonsKey = GlobalKey();
  GlobalKey dialogKey = GlobalKey();
  final TextEditingController textController = TextEditingController();
  IdeasRelation? targetRelation = state.ref.read(selectedRelation);

  if(targetRelation != null) {
    if(targetRelation.fromShape == RelationShape.dot && targetRelation.toShape == RelationShape.dot) {
      isSelected[0] = true;
    } else if(targetRelation.fromShape == RelationShape.dot && targetRelation.toShape == RelationShape.arrow) {
      // originとrelationの左右の位置によって、反映対象を変える
      if (targetRelation.fromIdea.parentLane!.index <= targetRelation.toIdea.parentLane!.index) {
        isSelected[1] = true;
      } else {
        isSelected[2] = true;
      }
    } else if(targetRelation.fromShape == RelationShape.arrow && targetRelation.toShape == RelationShape.dot) {
      if (targetRelation.fromIdea.parentLane!.index <= targetRelation.toIdea.parentLane!.index) {
        isSelected[2] = true;
      } else {
        isSelected[1] = true;
      }
    } else if(targetRelation.fromShape == RelationShape.arrow && targetRelation.toShape == RelationShape.arrow) {
      isSelected[3] = true;
    }
    textController.text = targetRelation.note;
  }

  return showDialog(
    context: context,
    builder: (context) {
      return StatefulBuilder(
        builder: (context, setState) {
          return SimpleDialog(
            key: dialogKey,
            contentPadding: const EdgeInsets.all(26),
            children: [
              Align( // 閉じるボタン
                alignment: Alignment.centerRight,
                child: Tooltip(
                  message: MaterialLocalizations
                      .of(context)
                      .closeButtonTooltip,
                  child: GestureDetector(
                    onTap: () => Navigator.pop(context),
                    child: const Icon(Icons.close, size: 30),
                  ),
                ),
              ),
              const Text('矢印の種類'),
              Container(height: 10,),
              ToggleButtons(
                key: toggleButtonsKey,
                direction: Axis.vertical,
                borderColor: Colors.white,
                selectedColor: Colors.lightBlueAccent,
                selectedBorderColor: Colors.white,
                isSelected: isSelected,
                onPressed: (index) {
                  // The button that is tapped is set to true, and the others to false.
                  setState(() {
                    for (int i = 0; i < isSelected.length; i++) {
                      isSelected[i] = i == index;
                    }
                  });
                },
                children: [
                  SizedBox(
                    height: 40,
                    width: 180,
                    child: Stack(
                      children: drawRelationLineAt(state, 30,20,150,20, RelationShape.dot, RelationShape.dot, null),
                    ),
                  ),
                  SizedBox(
                    height: 40,
                    width: 180,
                    child: Stack(
                      children: drawRelationLineAt(state, 30,20,150,20, RelationShape.dot, RelationShape.arrow, null),
                    ),
                  ),
                  SizedBox(
                    height: 40,
                    width: 180,
                    child: Stack(
                      children: drawRelationLineAt(state, 30,20,150,20, RelationShape.arrow, RelationShape.dot, null),
                    ),
                  ),
                  SizedBox(
                    height: 40,
                    width: 180,
                    child: Stack(
                      children: drawRelationLineAt(state, 30,20,150,20, RelationShape.arrow, RelationShape.arrow, null),
                    ),
                  ),
                ],
              ),
              Container(height: 10),
              const Text('線の説明'),
              TextField(
                controller: textController,
                decoration: InputDecoration(
                  border: OutlineInputBorder(borderRadius: BorderRadius.circular(10)),
                ),
              ),
              Container(height: 15),
              const Divider(),
              Container(height: 15),
              Align(alignment: Alignment.center, child:
              SizedBox(
                width: 120,
                height: 40,
                child:
                  ElevatedButton(
                    onPressed: () { // 決定ボタン押下時
                      if (isSelected[0]) {
                        targetRelation!.fromShape = RelationShape.dot;
                        targetRelation.toShape = RelationShape.dot;
                      } else if (isSelected[1]) {
                        // originとrelationの左右の位置によって、反映対象を変える
                        if (targetRelation!.fromIdea.parentLane!.index <= targetRelation.toIdea.parentLane!.index) {
                          targetRelation.fromShape = RelationShape.dot;
                          targetRelation.toShape = RelationShape.arrow;
                        } else {
                          targetRelation.fromShape = RelationShape.arrow;
                          targetRelation.toShape = RelationShape.dot;
                        }
                      } else if (isSelected[2]) {
                        // originとrelationの左右の位置によって、反映対象を変える
                        if (targetRelation!.fromIdea.parentLane!.index <= targetRelation.toIdea.parentLane!.index) {
                          targetRelation.fromShape = RelationShape.arrow;
                          targetRelation.toShape = RelationShape.dot;
                        } else {
                          targetRelation.fromShape = RelationShape.dot;
                          targetRelation.toShape = RelationShape.arrow;
                        }
                      } else if (isSelected[3]) {
                        targetRelation!.fromShape = RelationShape.arrow;
                        targetRelation.toShape = RelationShape.arrow;
                      }
                      targetRelation!.note = textController.text;
                      Navigator.pop(context);
                    },
                    child: const Text('決定')
                  ),
                ),
              ),
              Container(height: 15),
              Align(alignment: Alignment.bottomCenter, child:
                Consumer(
                  builder: (context, ref, child) {
                    return TextButton(
                      style: ButtonStyle(
                        foregroundColor: MaterialStateProperty.all<Color>(Colors.redAccent),
                      ),
                      onPressed: () async {
                        // 確認ダイアログを表示
                        var result = await showDialog<bool>(
                          context: context,
                          barrierDismissible: false,
                          builder: (BuildContext context) {
                            return AlertDialog(
                              title: const Text('確認'),
                              content: const Text('線を削除してよろしいですか?'),
                              actions: <Widget>[
                                TextButton(
                                  child: const Text('Cancel'),
                                  onPressed: () => Navigator.of(context).pop(false),
                                ),
                                TextButton(
                                  child: const Text('OK'),
                                  onPressed: () => Navigator.of(context).pop(true),
                                ),
                              ],
                            );
                          },
                        );
                        if (result == true) {
                          //リレーション削除の実行
                          removeRelation( ref.watch(ideasRelationList.notifier).state , targetRelation! );
                          Navigator.pop(context);
                        }
                      },
                      child: const Text('線の削除')
                    );
                  }
                ),
              ),
            ],
          );
        },
      );
    },
  );
}

Size _getTextSize(String text, TextStyle style) {
  final TextPainter textPainter = TextPainter(
      text: TextSpan(text: text, style: style), maxLines: 1, textDirection: TextDirection.ltr)
    ..layout(minWidth: 0, maxWidth: double.infinity);
  return textPainter.size;
}

// 指定した座標を元にリレーションの線と文字を描画
List <Widget> drawRelationLineAt(CanvasZoneState state, double startX, double startY, double endX, double endY, RelationShape startShape, RelationShape endShape, IdeasRelation? ideasRelation) {
  List <Widget> list = [];
  final double dotSize = state.ref.watch(relationDotSize);
  final double arrowSize = state.ref.watch(relationArrowSize);
  double rotateAdjustX = 0;
  double rotateAdjustY = 0;
  double rotateAdjustXForTransparent = 0;
  double rotateAdjustYForTransparent = 0;
  double scrollDeltaX = 0;//state.ref.watch(canvasScrollDeltaX);
  bool hoverFlg = false;
  double lineWidthDiv2 = 1;

  void focusEnter() {
    state.ref.watch(relationHoverFlg.notifier).state = true;
    state.ref.watch(selectedRelation.notifier).state = ideasRelation;
  }
  void focusExit() {
    state.ref.watch(relationHoverFlg.notifier).state = false;
    state.ref.watch(selectedRelation.notifier).state = null;
  }

  if(ideasRelation != null) {
    if ( state.ref.watch(relationHoverFlg) && state.ref.read(selectedRelation) == ideasRelation ) {
      hoverFlg = true;
    }
  }

  // 指定座標にドットを描画
  Widget makeDotAt(double dx, double dy) {
    return Positioned(
      top: dy - (dotSize / 2),
      left: dx - (dotSize / 2) + scrollDeltaX,
      width: dotSize,
      height: dotSize,
      child: MouseRegion(
        onEnter: (_) => focusEnter(),
        onExit: (_) => focusExit(),
        child: Container(
          decoration: BoxDecoration(
            color: hoverFlg ? Colors.lightBlueAccent : Colors.blue,
            shape: BoxShape.circle,
          ),
        ),
      ),
    );
  }

  /* ドットと三角の描画 */
  if(startShape == RelationShape.dot) {
    list.add( //ドットの描画 Origin
      makeDotAt( startX, startY )
    );
  } else {
    list.add( //三角の描画
      Positioned(
        top: startY - (arrowSize / 2),
        left: startX - (arrowSize / 2) + scrollDeltaX,
        child: Transform.rotate(
          angle: (startX == endX) /*レーンが同じ場合*/ ? 0 : getRadian(startX + scrollDeltaX, startY, endX + scrollDeltaX, endY),
          alignment: Alignment.center,
          child: MouseRegion(
            onEnter: (_) => focusEnter(),
            onExit: (_) => focusExit(),
            child: CustomPaint(
              painter: TrianglePainterL(hoverFlg ? Colors.lightBlueAccent : Colors.blue),
              child: SizedBox(
                width: arrowSize,
                height: arrowSize,
              ),
            ),
          ),
        ),
      ),
    );
  }
  if(endShape == RelationShape.dot) {
    list.add( //ドットの描画 Relation
      makeDotAt( endX, endY )
    );
  } else {
    list.add( //三角の描画
      Positioned(
        top: endY - (arrowSize / 2),
        left: endX - (arrowSize / 2) + scrollDeltaX,
        child: Transform.rotate(
          angle: (startX == endX) /*レーンが同じ場合*/ ? (180 * pi / 180) : getRadian(startX + scrollDeltaX, startY, endX + scrollDeltaX, endY),
          alignment: Alignment.center,
          child: MouseRegion(
            onEnter: (_) => focusEnter(),
            onExit: (_) => focusExit(),
            child: CustomPaint(
              painter: TrianglePainterR(hoverFlg ? Colors.lightBlueAccent : Colors.blue),
              child: SizedBox(
                width: arrowSize,
                height: arrowSize,
              ),
            ),
          ),
        ),
      ),
    );
  }

  double lineTop=0,lineLeft=0,lineHeight=0,lineWidth=0;
  /* 線の描画 */
  if (startX != endX) {
    /*レーンが違う場合*/
    if ( (endY - startY) > 30) { //右側の点が下側。回転すると、線の太さだけずれて見えてしまうので、それを調整する
      rotateAdjustX = 1;
      rotateAdjustY = 1;
      rotateAdjustXForTransparent = 2;
      rotateAdjustYForTransparent = 0;
    } else if ( (endY - startY) < -30) { //右側の点が上側。回転すると、線の太さだけずれて見えてしまうので、それを調整する
      rotateAdjustX = -1;
      rotateAdjustY = 0;
      rotateAdjustXForTransparent = -2;
      rotateAdjustYForTransparent = 0;
    }
    lineTop   = startY - lineWidthDiv2 + rotateAdjustY;
    lineLeft  = startX + lineWidthDiv2 + scrollDeltaX + rotateAdjustX;
    lineWidth = getDistance(startX + scrollDeltaX, startY, endX + scrollDeltaX, endY) - 2;
    lineHeight= 2;
    list.add( //線の描画
      Stack(
        children:[
          Positioned( //実際の線の描画
            top:  lineTop,
            left: lineLeft,
            child: Transform.rotate(
              angle: getRadian(startX + scrollDeltaX, startY, endX + scrollDeltaX, endY),
              alignment: Alignment.topLeft,
              origin: const Offset( -0.5, -0.5 ),
              child: Container(
                width: lineWidth,
                height: lineHeight,
                decoration: BoxDecoration(
                  color: hoverFlg ? Colors.lightBlueAccent : Colors.blue,
                ),
              ),
            ),
          ),
          Positioned( //当たり判定用の透明な線
            top:  startY - lineWidthDiv2 + rotateAdjustY + rotateAdjustYForTransparent - 3/*線の太さ分を調整*/,
            left: startX + lineWidthDiv2 + scrollDeltaX + rotateAdjustXForTransparent + rotateAdjustX - 1,
            child: Transform.rotate(
              angle: getRadian(startX + scrollDeltaX, startY, endX + scrollDeltaX, endY),
              alignment: Alignment.topLeft,
              //          origin: Offset( state.ref.watch(relationDotSize)/2 , state.ref.watch(relationDotSize)/2 ),
              child: MouseRegion(
                onEnter: (_) => focusEnter(),
                onExit: (_) => focusExit(),
                child: Container(
                  width: getDistance(startX + scrollDeltaX, startY, endX + scrollDeltaX, endY),
                  height: 8,
                  decoration: const BoxDecoration( color: relationColorTransparent ),
                ),
              ),
            ),
          ),
        ]
      ),
    );
  } else {
    /*レーンが同じ場合*/
    double horizontalMargin = ideaHorizontalMargin - 2;
    lineTop   = startY - lineWidthDiv2;
    lineLeft  = startX + scrollDeltaX + horizontalMargin;
    lineWidth = 2;
    lineHeight= endY- startY + (lineWidthDiv2*2) /*線の太さ分を調整*/;
    list.add( //線の描画
      Stack(
        children:[
          Positioned( //実際の線の描画(縦)
            top:  lineTop,
            left: lineLeft,
            child: Container(
              width: 2,
              height: lineHeight,
              decoration: BoxDecoration(
                color: hoverFlg ? Colors.lightBlueAccent : Colors.blue,
              ),
            ),
          ),
          Positioned( //実際の線の描画(横1)
            top:  startY - lineWidthDiv2,
            left: startX + lineWidthDiv2 + scrollDeltaX,
            child: Container(
              width: horizontalMargin,
              height: 2,
              decoration: BoxDecoration(
                color: hoverFlg ? Colors.lightBlueAccent : Colors.blue,
              ),
            ),
          ),
          Positioned( //実際の線の描画(横2)
            top:  endY - lineWidthDiv2,
            left: endX + lineWidthDiv2 + scrollDeltaX,
            child: Container(
              width: horizontalMargin,
              height: 2,
              decoration: BoxDecoration(
                color: hoverFlg ? Colors.lightBlueAccent : Colors.blue,
              ),
            ),
          ),
          Positioned( //当たり判定用の透明な線(縦)
            top:  startY - lineWidthDiv2 - 2 /*線の太さ分を調整*/,
            left: startX + scrollDeltaX + horizontalMargin - 2,
            child: MouseRegion(
              onEnter: (_) => focusEnter(),
              onExit:  (_) => focusExit(),
              child: Container(
                width: 2 + 4,
                height: endY - startY + (lineWidthDiv2*2) /*線の太さ分を調整*/ + 4,
                decoration: const BoxDecoration( color: relationColorTransparent ),
              ),
            ),
          ),
          Positioned( //当たり判定用の透明な線(横1)
            top:  startY - lineWidthDiv2 - 2 /*線の太さ分を調整*/,
            left: startX + lineWidthDiv2 + scrollDeltaX,
            child: MouseRegion(
              onEnter: (_) => focusEnter(),
              onExit:  (_) => focusExit(),
              child: Container(
                width: horizontalMargin,
                height: 2 + 4,
                decoration: const BoxDecoration( color: relationColorTransparent ),
              ),
            ),
          ),
          Positioned( //当たり判定用の透明な線(横2)
            top:  endY - lineWidthDiv2 - 2 /*線の太さ分を調整*/,
            left: endX + lineWidthDiv2 + scrollDeltaX,
            child: MouseRegion(
              onEnter: (_) => focusEnter(),
              onExit:  (_) => focusExit(),
              child: Container(
                width: horizontalMargin,
                height: 2 + 4,
                decoration: const BoxDecoration( color: relationColorTransparent ),
              ),
            ),
          ),
        ]
      ),
    );
  }

  String note = '';
  if(ideasRelation != null) {
    note = ideasRelation.note;
  }
  /* 文字の描画 */
  if ( note != '' ) {
    /* 線の描画 */
    if (startX != endX) {
      /*レーンが違う場合*/
      double radian = getRadian(startX + scrollDeltaX, startY, endX + scrollDeltaX, endY);

      // radianのイメージ
      // -90° = (-90* pi / 180) =-1.5707963267948966
      // -45° = (-45* pi / 180) =-0.7853981633974483
      //  0°  = ( 0 * pi / 180) = 0
      //  45° = (45 * pi / 180) = 0.7853981633974483
      //  90° = (90 * pi / 180) = 1.5707963267948966
      // 180° = pi              = 3.14

      // 手入力での調整結果
      // -90° left: - 18,   top:  - 0,
      // -75° left: - 15,   top:  - 8,
      // -45° left: - 12,   top: - 12,
      // -30° left: - 5,    top: - 15,
      //  0°  left: - 0,    top: - 18,
      //
      // Offset(13*radian,  -19 + (5*radian.abs())),
      // -90° left: -20.4,  top: - 19+7.8= -11.2
      // -45° left: -10.2,  top: - 19+3.9= -15.1
      //  0°  left:   0,    top: - 19,
      //  45° left:  10.2,  top: - 19+3.9= -15.1
      //  90° left:  20.4,  top: - 19+7.8= -11.2
      //
      //print("$radian (-45 * pi / 180):${(-45 * pi / 180)} (90 * pi / 180):${(90 * pi / 180)} (180 * pi / 180):${(180 * pi / 180)}");

      list.add( //文字の描画
        Stack(
          children: [
            Positioned( //文字の描画
              top: lineTop,
              left: lineLeft,
              width: lineWidth,
              child: Align(
                alignment: Alignment.center,
                child: Transform.translate(
                  offset: Offset(13 * radian, -19 + (4 * radian.abs())),
                  child: Transform.rotate(
                    angle: radian,
                    alignment: Alignment.topLeft,
                    origin: const Offset(-0.5, -0.5),
                    child: Container(
                      color: Colors.transparent,
                      alignment: Alignment.center,
                      child: Text(note, style: const TextStyle(color: Colors.blue),),
                    ),
                  ),
                ),
              ),
            ),
          ]
        ),
      );
    } else {
      /*レーンが同じ場合*/
      double radian = getRadian(lineLeft, lineTop, lineLeft, lineTop+lineHeight);
      Size textSize = _getTextSize(note, const TextStyle(color: Colors.blue));
      list.add( //文字の描画
        Stack(
          children: [
            Positioned( //文字の描画
              top: lineTop,
              left: lineLeft,
              child: Align(
                alignment: Alignment.center,
                child: Transform.translate(
                  offset: Offset(textSize.height + 4, (lineHeight/2) - (textSize.width/2) ),
                  child: Transform.rotate(
                    angle: radian,
                    alignment: Alignment.topLeft,
                    origin: const Offset(-0.5, -0.5),
                    child: Container(
                      color: Colors.transparent,
                      alignment: Alignment.center,
                      child: Text(note, style: const TextStyle(color: Colors.blue),),
                    ),
                  ),
                ),
              ),
            ),
          ]
        ),
      );
    }
  }
  //デバッグ用
  if(relationDebugPaint) {
    list.add(
      Positioned( //デバッグ用
        top: startY,
        left: startX + scrollDeltaX,
        width: 1,
        height: 1,
        child: Container(
          decoration: const BoxDecoration(
            color: Colors.red,
            shape: BoxShape.circle,
          ),
        ),
      ),
    );
    list.add(
      Positioned( //デバッグ用
        top: startY - lineWidthDiv2 + rotateAdjustY,
        left: startX + lineWidthDiv2 + scrollDeltaX,
        width: 1,
        height: 1,
        child: Container(
          decoration: const BoxDecoration(
            color: Colors.orange,
            shape: BoxShape.circle,
          ),
        ),
      ),
    );
  }

  return list;
}

// relationを元にリレーション描画ウィジェットを作成
List<Widget> buildRelationLineWidget(CanvasZoneState state, IdeasRelation relationSet) {
  Idea startIdea, endIdea;
  RelationShape startShape, endShape;
  double startX, startY, endY, endX;
  final double laneWidth = state.ref.watch(eachLaneWidth);
  final double ideaHeight = state.ref.watch(eachIdeaHeight);

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

    // 描画位置から求めると、パン・スクロールしたときにどんどんずれていく
    // RenderBox originIdeaTextFieldBox = origin.textFieldKey.currentContext?.findRenderObject() as RenderBox;
    startY = (ideaHeight * startIdea.index) + (ideaHeight/2) - 0.5/*調整用。これが無いとずれて見える*/ ;
    startX = (startIdea.parentLane!.index + 1) * laneWidth - ideaHorizontalMargin - 0.5/*調整用。これが無いとずれて見える*/;
    endY = (ideaHeight * endIdea.index) + (ideaHeight/2) - 0.5/*調整用。これが無いとずれて見える*/;
    endX = (laneWidth * endIdea.parentLane!.index) + ideaHorizontalMargin + ideaLeftSpace + (indentWidth * endIdea.indentLevel) + 0.5/*調整用。これが無いとずれて見える*/;

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

    /* 見やすくするためのずらし調整(他の矢印やドットとの重なり:右側) */
    if ( (endY - startY).abs() > 10) { // 線に角度が付いている場合
      bool avoidFlg = false;
      //対象のアイディアに、他にリレーションが付いているか探す
      if(endIdea.relatedIdeasFrom.length >= 2) {
        avoidFlg = true;
      }
      if (avoidFlg) {
        if (startY < endY - 10) { // 矢印が斜めの場合
          endY -= 6; //Related側の矢印と重なるのを避けるために少しずらす
        } else if (startY > endY) {
          endY += 6;
        }
      }
      /* 見やすくするためのずらし調整(他の矢印やドットとの重なり:右側) */
      avoidFlg = false;
      //対象のアイディアに、他にリレーションが付いているか探す
      if(startIdea.relatedIdeasTo.length >= 2) {
        avoidFlg = true;
      }
      if (avoidFlg) {
        if (startY < endY - 10) { // 矢印が斜めの場合
          startY += 6; //Related側の矢印と重なるのを避けるために少しずらす
        } else if (startY > endY) {
          startY -= 6;
        }
      }
    }
  } else{
    /* 同じレーン同士の場合 */
    // 計算を簡単にするため、上を必ずstartIdeaとしてしまう
    if (relationSet.fromIdea.index <= relationSet.toIdea.index) {
      startIdea = relationSet.fromIdea;
      startShape = relationSet.fromShape;
      endIdea = relationSet.toIdea;
      endShape = relationSet.toShape;
    } else {
      startIdea = relationSet.toIdea;
      startShape = relationSet.toShape;
      endIdea = relationSet.fromIdea;
      endShape = relationSet.fromShape;
    }

    startY = (ideaHeight * startIdea.index) + (ideaHeight/2) - 0.5/*調整用。これが無いとずれて見える*/ ;
    startX = (startIdea.parentLane!.index + 1) * laneWidth - ideaHorizontalMargin - 0.5/*調整用。これが無いとずれて見える*/;
    endY = (ideaHeight * endIdea.index) + (ideaHeight/2) - 0.5/*調整用。これが無いとずれて見える*/;
    endX = startX;

    /* 見やすくするためのずらし調整(他の矢印やドットとの重なり:右側) */
    bool avoidFlg = false;
    //対象のアイディアに、他にリレーションが付いているか探す
    if(startIdea.relatedIdeasTo.length >= 2) {
      avoidFlg = true;
    }
    if (avoidFlg) {
      if (startY < endY - 10) { // 矢印が斜めの場合
        startY += 6; //Related側の矢印と重なるのを避けるために少しずらす
      } else if (startY > endY) {
        startY -= 6;
      }
    }
  }

  if(relationDebugPaint) {
    List<Widget> result = drawRelationLineAt(state, startX,startY,endX,endY, startShape,endShape, relationSet) ;
    //座標に関するデバッグ用 originの表示
    result.add(
        Stack(children: [
          Positioned( //デバッグ用 originの表示
            top: startIdea == relationSet.fromIdea ? startY : endY,
            left: startIdea == relationSet.fromIdea ? startX + state.ref.watch(canvasScrollDeltaX) : endX + state.ref.watch(canvasScrollDeltaX),
            child: Container(
              width: 3,
              height: 3,
              decoration: const BoxDecoration(color: Colors.deepPurpleAccent,),
            ),
          ),
        ])
    );
    //デバッグ用 dotの中心線
    result.add(
        Stack(children: [
          Positioned( //デバッグ用
            top: endY,
            left: endX + state.ref.watch(canvasScrollDeltaX),
            child: Container(
              width: 500,
              height: 0.5,
              decoration: const BoxDecoration(color: Colors.red,),
            ),
          ),
        ])
    );
    return result;
  } else {
    return drawRelationLineAt(state, startX,startY,endX,endY, startShape,endShape, relationSet) ;
  }
}

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: laneWidth * 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: laneWidth,
            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;

  /* Relation */
  IdeasRelation? lazyBuildTarget;
  for (IdeasRelation relationSet in state.ref.watch(ideasRelationList)) {
    if ( state.ref.watch(relationHoverFlg) && state.ref.read(selectedRelation) == relationSet ) {
      //アクティブな線は、最後に描画することで手前に持ってくる(そうしないと他の線に隠れてしまう)
      lazyBuildTarget = relationSet;
    } else {
      list.addAll( buildRelationLineWidget(state, relationSet) );
    }
  }
  if(lazyBuildTarget != null) {
    list.addAll( buildRelationLineWidget(state, lazyBuildTarget) );
  }

/*
  //デバッグ用
  list.add(
    Stack(children: [
      Positioned( //デバッグ用
        top:  0,
        left: state.ref.watch(eachLaneWidth) - ideaHorizontalMargin - 50 + state.ref.watch(canvasScrollDeltaX),
        child: Container(
          width: 500,
          height: 1,
          decoration: BoxDecoration(color: Colors.red,),
        ),
      ),
      Positioned( //デバッグ用
        top:  (ideaHeight / 2),
        left: state.ref.watch(eachLaneWidth) - ideaHorizontalMargin - 50 + state.ref.watch(canvasScrollDeltaX),
        child: Container(
          width: 1000,
          height: 0.5,
          decoration: BoxDecoration(color: Colors.green,),
        ),
      ),
    ],
    ),
  );
*/

  return list;
}

void indentLinePaint(Canvas canvas,Size size,BuildContext context, int index, int indentLevel, double textFieldHeight, 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) - (ideaVerticalMargin * 2);

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

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

void showAlert( String msg, BuildContext context) {
  showDialog(
    context: context,
    builder: (BuildContext context) {
      return AlertDialog(
        content: Text(msg),
        actions: [
          TextButton(
            onPressed: () {
              Navigator.pop(context, 'OK');
            },
            child: const Text('OK'))
        ],);
    } );
}

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>> ideasRelationJson = [];
  state.ref.read(ideasRelationList).forEach((relationSet) {
    ideasRelationJson.add({
      'from_lane_index': relationSet.fromIdea.parentLane?.index,
      'from_idea_index': relationSet.fromIdea.index,
      'from_shape':      relationSet.fromShape == RelationShape.dot ? 'dot' : 'arrow',
      'to_lane_index': relationSet.toIdea.parentLane?.index,
      'to_idea_index': relationSet.toIdea.index,
      'to_shape':      relationSet.toShape == RelationShape.dot ? 'dot' : 'arrow',
      'note':               relationSet.note,
    });
  });

  String jsonString = jsonEncode({'lanes': lanesJson, 'ideasRelation': ideasRelationJson});
  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) {
    showAlert('No such files:{$fileName}', state!.context);
    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(ideasRelationList.notifier).state.clear();

  if (jsonMap['ideasRelation'] != null) {
    for (var ideaRelationJson in jsonMap['ideasRelation']) {
      var fromLaneIndex = ideaRelationJson['from_lane_index'];
      var fromIdeaIndex = ideaRelationJson['from_idea_index'];
      var toLaneIndex = ideaRelationJson['to_lane_index'];
      var toIdeaIndex = ideaRelationJson['to_idea_index'];
      IdeasRelation relationSet = IdeasRelation(
          fromIdea: loadedLanes[fromLaneIndex].ideas[fromIdeaIndex], toIdea: loadedLanes[toLaneIndex].ideas[toIdeaIndex]);
      if(ideaRelationJson['from_shape'] == 'arrow') {
        relationSet.fromShape = RelationShape.arrow;
      }
      if(ideaRelationJson['to_shape'] == 'arrow') {
        relationSet.toShape = RelationShape.arrow;
      }
      relationSet.note = ideaRelationJson['note'];
      state.ref.watch(ideasRelationList.notifier).state.add(relationSet);

      loadedLanes[fromLaneIndex].ideas[fromIdeaIndex].relatedIdeasTo.add(loadedLanes[toLaneIndex].ideas[toIdeaIndex]);
      loadedLanes[toLaneIndex].ideas[toIdeaIndex].relatedIdeasFrom.add(loadedLanes[fromLaneIndex].ideas[fromIdeaIndex]);
    }
  }
}

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

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

コメント

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