FlutterとNCMBで地図メモアプリを作る（その2: 地図の表示とメモの追加）

スマートフォンと地図は相性が良いです。スマホは持ち歩いて使うのが基本ですし、位置情報などの情報も取得できます。

今回はNCMBとFlutterを使って地図上にメモできる地図メモアプリを作ります。地図はOpenStreetMapのものを利用し、タップした場所にメモと写真を残しておけるアプリです。

[前回の記事では画面の説明とSDKの導入までを進めました](https://qiita.com/goofmint/items/7cbbeeb6764b81df3b82)ので、今回は地図表示とデータの登録を実装していきます。

## コードについて

今回のコードは [flutter_map_note](https://github.com/NCMBMania/flutter_map_note) にアップロードしてあります。実装時の参考にしてください。

## メイン画面を表示する

アプリの `MyApp` ウィジェットで、 `MainPage` ウィジェットを読み込みます。これは地図と一覧のタブを表示しています。

```dart
class MyApp extends StatelessWidget {
  final mapboxAccessToken = '';
  const MyApp({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.lightBlue,
      ),
      home: const MainPage(),
    );
  }
}
```

`MainPage` ウィジェットは以下の通りです。メモの一覧を管理する `_notes` と、地図を中心点 `_location` の2つは画面間で共有するために `MainPage` にて管理しています。

```dart
import 'package:flutter/material.dart';
import 'package:latlng/latlng.dart';
import 'package:ncmb/ncmb.dart';
import './map_page.dart';
import './list_page.dart';

// 最初の画面用のStatefulWidget
class MainPage extends StatefulWidget {
  const MainPage({Key? key}) : super(key: key);
  @override
  State<MainPage> createState() => _MainPageState();
}

class _MainPageState extends State<MainPage> {
  // タイトル
  final title = '地図アプリ';

  // 表示するタブ
  final _tab = <Tab>[
    const Tab(text: '地図', icon: Icon(Icons.map_outlined)),
    const Tab(text: 'リスト', icon: Icon(Icons.list_outlined)),
  ];

  List<NCMBObject> _notes = [];
  final LatLng _location = const LatLng(35.6585805, 139.7454329);

  void _setNotes(List<NCMBObject> notes) {
    setState(() {
      _notes = notes;
    });
  }

  // AppBarとタブを表示
  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: _tab.length,
      child: Scaffold(
        appBar: AppBar(
          title: Text(title),
          bottom: TabBar(
            tabs: _tab,
          ),
        ),
        body: TabBarView(children: [
          MapPage(setNotes: _setNotes, location: _location),
          ListPage(notes: _notes, location: _location),
        ]),
      ),
    );
  }
}
```

## 地図を表示する

以下は `MapPage` のコードです。[map \| Flutter Package](https://pub.dev/packages/map)用のコードがほとんどです。

```dart
import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import 'package:latlng/latlng.dart';
import 'package:map/map.dart';
import 'package:ncmb/ncmb.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'dart:math';
import './bubble_border.dart';
import './note_page.dart';

// 地図画面用のStatefulWidget
class MapPage extends StatefulWidget {
  const MapPage({Key? key, required this.setNotes, required this.location})
      : super(key: key);
  final Function(List<NCMBObject>) setNotes;
  final LatLng location;
  @override
  State<MapPage> createState() => _MapPageState();
}

// 地図画面
class _MapPageState extends State<MapPage> {
  // 地図コントローラーの初期化
  MapController? controller;
  // ドラッグ操作用
  Offset? _dragStart;
  double _scaleStart = 1.0;

  // 表示するマーカー
  List<Widget> _markers = [];
  Widget? _tooltip;

  @override
  void initState() {
    super.initState();
    controller = MapController(
      location: widget.location,
      zoom: 15,
    );
  }

  // 初期のスケール情報
  void _onScaleStart(ScaleStartDetails details) {
    _dragStart = details.focalPoint;
    _scaleStart = 1.0;
  }

  // センターが変わったときの処理
  void _onScaleUpdate(MapTransformer transformer, ScaleUpdateDetails details) {
    final scaleDiff = details.scale - _scaleStart;
    _scaleStart = details.scale;
    if (scaleDiff > 0) {
      controller!.zoom += 0.02;
      setState(() {});
    } else if (scaleDiff < 0) {
      controller!.zoom -= 0.02;
      if (controller!.zoom < 1) {
        controller!.zoom = 1;
      }
      setState(() {});
    } else {
      final now = details.focalPoint;
      var diff = now - _dragStart!;
      _dragStart = now;
      final h = transformer.constraints.maxHeight;

      final vp = transformer.getViewport();
      if (diff.dy < 0 && vp.bottom - diff.dy < h) {
        diff = Offset(diff.dx, 0);
      }

      if (diff.dy > 0 && vp.top - diff.dy > 0) {
        diff = Offset(diff.dx, 0);
      }
      transformer.drag(diff.dx, diff.dy);
      setState(() {});
    }
  }

  void _onScaleEnd(MapTransformer transformer) {
    // マーカーを表示する
    showNotes(transformer);
  }

  // マーカーウィジェットを作成する
  Future<Widget> _buildMarkerWidget(
      NCMBObject note, MapTransformer transformer) async {
    // 後述
  }

  Future<void> _onTap(double top, double left, NCMBObject note) async {
    final image = await NCMBFile.download(note.getString('image'));
    final tooltip = Container(
      margin: const EdgeInsets.only(left: 15.0),
      padding: const EdgeInsets.symmetric(
        vertical: 5.0,
        horizontal: 10.0,
      ),
      child: Column(children: [
        Text(note.getString('text')),
        Text(note.getString('address', defaultValue: "不明") + "付近のメモ"),
        SizedBox(
          child: Image.memory(image.data),
          height: 200,
        )
      ]),
      decoration: const ShapeDecoration(
        color: Colors.white,
        shape: BubbleBorder(),
      ),
    );
    setState(() {
      _tooltip = Positioned(
        left: left - 170,
        top: top - 280,
        child: tooltip,
      );
    });
  }

  Future<void> showNotes(MapTransformer transformer) async {
    // 後述
  }

  Future<List<NCMBObject>> getNotes(LatLng location) async {
    // 後述
  }

  // 地図をタップした際のイベント
  void _onTapUp(MapTransformer transformer, TapUpDetails details) async {
    // 後述
  }

  double clamp(double x, double min, double max) {
    if (x < min) x = min;
    if (x > max) x = max;
    return x;
  }

  void _onDoubleTapDown(MapTransformer transformer, TapDownDetails details) {
    const delta = 0.5;
    final zoom = clamp(controller!.zoom + delta, 2, 18);
    transformer.setZoomInPlace(zoom, details.localPosition);
    setState(() {});
  }

  void _onPointerSignal(MapTransformer transformer, PointerSignalEvent event) {
    if (event is! PointerScrollEvent) return;
    final delta = event.scrollDelta.dy / -1000.0;
    final zoom = clamp(controller!.zoom + delta, 2, 18);
    transformer.setZoomInPlace(zoom, event.localPosition);
    setState(() {});
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: null,
      body: MapLayout(
        controller: controller!,
        builder: (context, transformer) {
          return GestureDetector(
            behavior: HitTestBehavior.opaque,
            // タップした際のズーム処理
            onDoubleTapDown: (details) =>
                _onDoubleTapDown(transformer, details),
            // ピンチ/パン処理
            onScaleStart: _onScaleStart,
            onScaleUpdate: (details) => _onScaleUpdate(transformer, details),
            onScaleEnd: (details) => _onScaleEnd(transformer),
            // タップした際の処理
            onTapUp: (details) async {
              _onTapUp(transformer, details);
            },
            child: Listener(
              behavior: HitTestBehavior.opaque,
              onPointerSignal: (event) => _onPointerSignal(transformer, event),
              child: Stack(children: [
                TileLayer(
                  builder: (context, x, y, z) {
                    final tilesInZoom = pow(2.0, z).floor();
                    while (x < 0) {
                      x += tilesInZoom;
                    }
                    while (y < 0) {
                      y += tilesInZoom;
                    }
                    x %= tilesInZoom;
                    y %= tilesInZoom;
                    return CachedNetworkImage(
                      imageUrl: 'https://tile.openstreetmap.org/$z/$x/$y.png',
                      fit: BoxFit.cover,
                    );
                  },
                ),
                ..._markers,
                _tooltip != null ? _tooltip! : Container(),
              ]),
            ),
          );
        },
      ),
    );
  }
}
```

### 地図をタップした際の処理

地図をタップした際には `_onTapUp` 関数が呼ばれます。この関数では、地図上のマーカーがタップされたのか、それ以外の部分なのかを判定して処理分けしています。

```dart
// 地図をタップした際のイベント
void _onTapUp(MapTransformer transformer, TapUpDetails details) async {
  // ツールチップが表示されている場合は非表示にして終了
  if (_tooltip != null) {
    setState(() {
      _tooltip = null;
    });
    return;
  }
  // 地図上のXY
  final location = transformer.toLatLng(details.localPosition);
  // メモ追加画面を表示
  Navigator.push(
    context,
    MaterialPageRoute(builder: (context) => NotePage(location: location)),
  );
}
```

上記関数での `NotePage` がノート画面になります。

## ノート画面での表示

ノート画面 `NotePage` は以下のようにフォームを表示しています。

```dart
@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: const Text('メモ'),
    ),
    body: Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          const Spacer(),
          const Text(
            '写真を選択して、メモを入力してください',
          ),
          _image != null
              ? GestureDetector(
                  child: SizedBox(
                    child: Image.memory(_image!),
                    height: 200,
                  ),
                  onTap: _selectPhoto,
                )
              : IconButton(
                  iconSize: 200,
                  icon: const Icon(
                    Icons.photo,
                    color: Colors.blue,
                  ),
                  onPressed: _selectPhoto,
                ),
          Spacer(),
          _address != null
              ? Text(
                  "$_address付近のメモ",
                  style: const TextStyle(fontSize: 20),
                )
              : const SizedBox(),
          const Spacer(),
          SizedBox(
            width: 300,
            child: TextFormField(
              controller: _textEditingController,
              enabled: true,
              style: const TextStyle(color: Colors.black),
              maxLines: 5,
              onChanged: (text) {
                setState(() {
                  _text = text;
                });
              },
            ),
          ),
          const Spacer(),
          ElevatedButton(onPressed: _onPressed, child: const Text('保存')),
          const Spacer(),
        ],
      ),
    ),
  );
}
```

### 初期化時に処理

初期化時には、以下のように国土地理院APIを使って位置情報を住所に変換します。

```dart
@override
void initState() {
	super.initState();
	_textEditingController = TextEditingController();
	_getAddress();
}

Future<void> _getAddress() async {
  if (widget.location == null) return;
  final lat = widget.location!.latitude;
  final lng = widget.location!.longitude;
  final uri = Uri.parse(
      "https://mreversegeocoder.gsi.go.jp/reverse-geocoder/LonLatToAddress?lat=$lat&lon=$lng");
  final response = await http.get(uri);
  final json = jsonDecode(response.body);
  final num = json['results']['muniCd'];
  String loadData = await rootBundle.loadString('csv/gsi.csv');
  final ary = loadData.split('\n');
  final line = ary.firstWhere((line) => line.split(',')[0] == num);
  final params = line.split(',');
  setState(() {
    _address = "${params[2]}${params[4]}${json['results']['lv01Nm']}";
  });
}
```

`csv/gsi.csv` は `[https://maps.gsi.go.jp/js/muni.js](https://maps.gsi.go.jp/js/muni.js)` のデータをCSVに変換したものです。

```dart
1100,1,北海道,1100,札幌市
1101,1,北海道,1101,札幌市　中央区
1102,1,北海道,1102,札幌市　北区
  ：
47375,47,沖縄県,47375,多良間村
47381,47,沖縄県,47381,竹富町
47382,47,沖縄県,47382,与那国町
```

### 写真選択時の処理

写真を選択する際には、写真アイコンをタップします。そして写真が選択されたら、プレビューを表示します。

```dart
Future<void> _selectPhoto() async {
  final pickedFile = await picker.pickImage(source: ImageSource.gallery);
  if (pickedFile == null) return;
  var image = await pickedFile.readAsBytes();
  setState(() {
    _extension = pickedFile.mimeType!.split('/')[1];
    _image = image;
  });
}
```

### ノートを保存

入力が終わったら、保存処理を実行します。選択されている位置情報はNCMBの位置情報オブジェクトに変換します。そして住所、メモと一緒に保存します。

```dart
Future<void> _onPressed() async {
  final fileName = "${const Uuid().v4()}.$_extension";
  final geo =
      NCMBGeoPoint(widget.location!.latitude, widget.location!.longitude);
  final obj = NCMBObject('Note');
  obj.sets({'text': _text, 'address': _address, 'geo': geo});
  if (_image != null) {
    await NCMBFile.upload(fileName, _image);
    obj.set('image', fileName);
  }
  await obj.save();
  Navigator.pop(context);
}
```

これでメモの保存処理が完成しました。

## まとめ

今回は地図の表示とメモの保存処理を実装しました。次回は[地図上へのマーカー表示と一覧表示処理](https://qiita.com/goofmint/items/40281168be84ef1cdf10)を実装します。