78歳からの挑戦!Flutterで作る「西国三十三所巡礼アプリ」開発からストア公開までの全記録

はじめに

本稿は、78歳という年齢でゼロからアプリ開発に挑戦し、App StoreおよびGoogle Play Storeにて「西国三十三所巡礼アプリ」をリリースした実践的な記録です。「スマホとパソコンを楽しむ会」での活動を通じ、同世代の仲間たちが「自分だけの巡礼の記録を手軽に残したい」と話していたことが開発のきっかけでした。

第0章:プログラミングの壁を下げる(入門編)〜ブラウザで動く超簡易版アプリ〜

「アプリ開発」と聞くと、難しい専用ソフトをインストールして暗号のような文字を打ち込む……そんなイメージを持たれるかもしれません。そこで本章では、本格的な世界へ飛び込む前の「準備運動」として、パソコンの「メモ帳」と「ブラウザ」だけで動く超簡易版・巡礼アプリを作ってみましょう。

1. メモ帳にコードを書く

Webサイトを作るための標準技術であるJavaScript(HTML/CSS/JS)を使います。以下のコードをコピーし、メモ帳に貼り付けて saigoku_mini.html として保存してください。

HTML

<!DOCTYPE html>

<html lang=”ja”>

<head>

<meta charset=”UTF-8″>

<meta name=”viewport” content=”width=device-width, initial-scale=1.0″>

<title>かんたん巡礼アプリ</title>

<style>

    body { font-family: sans-serif; background-color: #f5f5f5; margin: 0; padding: 20px; }

    h1 { color: #333; text-align: center; }

    ul { list-style: none; padding: 0; }

    li { background: white; margin-bottom: 10px; padding: 15px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); display: flex; justify-content: space-between; align-items: center; }

    .temple-name { font-size: 1.2em; font-weight: bold; }

    .btn-map { background: #4285f4; color: white; padding: 8px 12px; text-decoration: none; border-radius: 4px; font-size: 0.9em; }

    .check-area { display: flex; align-items: center; gap: 10px; }

    input[type=”checkbox”] { transform: scale(1.5); }

</style>

</head>

<body>

<h1>西国巡礼(お試し版)</h1>

<ul id=”temple-list”></ul>

<script>

// お寺のデータ(まずは3つだけ)

const temples = [

    { id: 1, name: “第1番 青岸渡寺”, keyword: “青岸渡寺” },

    { id: 2, name: “第2番 紀三井寺”, keyword: “紀三井寺” },

    { id: 3, name: “第3番 粉河寺”, keyword: “粉河寺” }

];

const listElement = document.getElementById(‘temple-list’);

// 画面にリストを自動で作る魔法

temples.forEach(temple => {

    // 過去のチェック状態を思い出す

    const isChecked = localStorage.getItem(‘temple_’ + temple.id) === ‘true’ ? ‘checked’ : ”;

    const li = document.createElement(‘li’);

    li.innerHTML = `

        <div class=”check-area”>

            <input type=”checkbox” id=”check_${temple.id}” ${isChecked} onchange=”saveCheck(${temple.id}, this.checked)”>

            <span class=”temple-name”>${temple.name}</span>

        </div>

        <!– 地図を開くボタン –>

        <a href=”https://www.google.com/maps/search/?api=1&query=${temple.keyword}” target=”_blank” class=”btn-map”>地図</a>

    `;

    listElement.appendChild(li);

});

// チェックした状態をセーブする魔法

function saveCheck(id, isChecked) {

    localStorage.setItem(‘temple_’ + id, isChecked);

}

</script>

</body>

</html>

2. アプリみたいな「3つのひみつ」

たった数十行のコードですが、ここには巡礼アプリに必要なエッセンスが詰まっています。

  • 自動で並べる「ループ」:お寺の数だけ同じ形のリストを自動で作ります(Flutterの ListView.builder と同じ考え方です)。
  • 地図連動:ボタンに [https://www.google.com/maps/search/?api=1&query=寺の名前](https://www.google.com/maps/search/?api=1&query=寺の名前) というGoogleマップ専用のURLを貼るだけで、APIキーの登録なしで現地の地図を開くことができます。
  • セーブ機能:ブラウザの中にある小さな引き出し(localStorage)を使って、チェックを記録します。

第1章:いざFlutterの世界へ!環境構築と基本設計

ブラウザ版の限界(オフラインで使えない、アイコンが置けない等)を越えるため、Googleが作った「Flutter」を使います。一つのコードでiPhone用とAndroid用の両方が作れる強力な武器です。

1. 必要なパッケージの準備

プロジェクトの設計図である pubspec.yaml に、アプリに必要な道具(パッケージ)をすべて書き込みます。

YAML

dependencies:

  flutter:

    sdk: flutter

  shared_preferences: ^2.2.3  # 訪問済みチェックの保存用

  image_picker: ^1.1.2        # カメラ起動用

  path_provider: ^2.1.3       # 写真の保存先取得用

  google_maps_flutter: ^2.5.3 # Google Map表示用

  geolocator: ^11.0.0         # 現在地と距離計算用

  path: ^1.9.0

  google_mobile_ads: ^5.1.0   # 広告表示用

2. カッコの迷宮を抜ける3つの武器

Flutterのコードは { } や ( ) が幾重にも入れ子になるため、最初は戸惑うかもしれません。私は以下の3つの方法でこれを克服しました。

  1. 「末尾のカンマ(,)」を打つ癖をつける: 閉じカッコの直前に , を入れて保存(自動整形)すると、コードが縦にきれいに並びます。これが最大の秘訣です。
  2. ガイドラインを見る: エディタの縦線を見て、どのカッコが対応しているか視覚的に把握します。
  3. コメントを機械に書かせる: Flutterの設定を有効にし、// Column のような閉じカッコの注釈を自動表示させます。

3. お寺のデータを作る「クラス」

単なる文字ではなく、「番号は数字(int)」「緯度経度は小数(double)」と厳密なルール(設計図)を定めます。このようにデータを一つの塊にすることで、後のリスト表示や地図連動が格段に作りやすくなります。

Dart

class Temple {

  final int number;

  final String name;

  final String address;

  final String description;

  final double lat;

  final double lng;

  Temple({

    required this.number,

    required this.name,

    required this.address,

    required this.description,

    required this.lat,

    required this.lng,

  });

}

第2章:地図とカメラの統合(審査対策の極意)

巡礼アプリの核となる「地図連携」と「カメラ機能」の実装です。ここでは各パーツが「どんな仕事をしているのか」を解剖してみましょう。

💡 プログラムの解剖①:現在地からお寺までの距離を計算する

巡礼中に「次のお寺まであと何キロだろう?」と確認できるナビゲーション機能です。

Dart

double? dist;

if (_pos != null) {

  dist = Geolocator.distanceBetween(

      _pos!.latitude, _pos!.longitude,

      t.lat, t.lng) / 1000;

}

Geolocator.distanceBetween に現在地とお寺の緯度・経度を渡すだけで、2点間の直線距離を自動で算出してくれます。計算結果は「メートル」で返ってくるため、1000で割ることで「キロメートル」に直しています。

💡 プログラムの解剖②:地図にピンを立てる

Googleマップ上に札所を表示し、「訪問済み」なら色を変える工夫です。

Dart

markers: shikokuTemples.map((t) {

  final isV = visitedIds.contains(t.number.toString());

  return Marker(

    markerId: MarkerId(t.number.toString()),

    position: LatLng(t.lat, t.lng),

    icon: BitmapDescriptor.defaultMarkerWithHue(

      isV ? BitmapDescriptor.hueGreen : BitmapDescriptor.hueRed,

    ),

  );

}).toSet(),

isV ? …hueGreen : …hueRed は、訪問済みなら緑色のピンを、まだなら赤色のピンを表示するという条件分岐です。

🚨 激闘!Apple審査のリジェクト対策

Appleの厳格な審査に直面し、プログラムの大幅な見直しを迫られました。

  • 対策①:iPadシミュレータの罠 審査官はカメラのない「iPadシミュレータ」を使うことがあります。いきなりカメラを起動するとアプリがクラッシュしたと判定されてしまうため、showModalBottomSheet を使って「カメラで撮影」か「写真ライブラリから選択」かを選べるダイアログをワンクッション挟むことで解決しました。
  • 対策②:タップ抜け(反応しない)を防ぐ魔法 大きな写真枠を審査官がタップしても反応しないと指摘されました。そこで、透明な部分でも確実にタップを検知する HitTestBehavior.opaque を GestureDetector に追加しました。
  • 対策③:ボタンが隠れないための透明なクッション 特定のスマホで一番下までスクロールした際、ボタンがナビゲーションバーに隠れてしまう問題がありました。そこでコードの最後に const SizedBox(height: 60) という透明な余白を入れることでレイアウト崩れを防ぎました。

第3章:完全版フルソースコード一式

これまでの解説を踏まえた、アプリのすべての機能が集約された完全版のコードです。これをコピーして使えば、そのまま動くアプリが完成します。

💻 main.dart (統合フルソース)

(※本稿では「四国八十八ヶ所仕様」のコードを掲載しています)

Dart

import ‘dart:io’;

import ‘dart:async’;

import ‘package:flutter/material.dart’;

import ‘package:shared_preferences/shared_preferences.dart’;

import ‘package:image_picker/image_picker.dart’;

import ‘package:path_provider/path_provider.dart’;

import ‘package:google_maps_flutter/google_maps_flutter.dart’;

import ‘package:geolocator/geolocator.dart’;

import ‘package:path/path.dart’ as p;

import ‘package:google_mobile_ads/google_mobile_ads.dart’;

void main() {

  WidgetsFlutterBinding.ensureInitialized();

  MobileAds.instance.initialize();

  runApp(const ShikokuApp());

}

class ShikokuApp extends StatelessWidget {

  const ShikokuApp({super.key});

  @override

  Widget build(BuildContext context) {

    return MaterialApp(

      title: ‘四国巡礼88ヶ所ナビ’,

      debugShowCheckedModeBanner: false,

      theme: ThemeData(

        colorScheme: ColorScheme.fromSeed(

          seedColor: Colors.teal,

          primary: Colors.teal[800]!,

        ),

        useMaterial3: true,

      ),

      home: const SplashScreen(),

    );

  }

}

class Temple {

  final int number;

  final String name;

  final String address;

  final String description;

  final double lat;

  final double lng;

  Temple({

    required this.number,

    required this.name,

    required this.address,

    required this.description,

    required this.lat,

    required this.lng,

  });

}

final List<Temple> shikokuTemples = [

  Temple(

    number: 1,

    name: “霊山寺”,

    address: “徳島県鳴門市大麻町板東塚鼻126”,

    description: “四国八十八ヶ所霊場の第1番札所。お遍路の旅立ちの寺として知られます。”,

    lat: 34.1601,

    lng: 134.5028,

  ),

  Temple(

    number: 2,

    name: “極楽寺”,

    address: “徳島県鳴門市大麻町桧字ダンノ上12”,

    description: “弘法大師手植えと伝わる長命杉が有名で、安産や長寿のご利益があります。”,

    lat: 34.1558,

    lng: 134.4883,

  ),

  // ※実際のアプリではここに全88箇所のデータを記述します

];

class SplashScreen extends StatefulWidget {

  const SplashScreen({super.key});

  @override

  State<SplashScreen> createState() => _SplashScreenState();

}

class _SplashScreenState extends State<SplashScreen> {

  @override

  void initState() {

    super.initState();

    Timer(const Duration(seconds: 3), () {

      if (mounted) {

        Navigator.of(context).pushReplacement(

          MaterialPageRoute(builder: (_) => const MainTabs()),

        );

      }

    });

  }

  @override

  Widget build(BuildContext context) {

    return Scaffold(

      backgroundColor: Colors.teal[50],

      body: Center(

        child: Column(

          mainAxisAlignment: MainAxisAlignment.center,

          children: [

            const Icon(Icons.temple_buddhist, size: 100, color: Colors.teal),

            const SizedBox(height: 20),

            Text(

              ‘四国巡礼88ヶ所ナビ’,

              style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: Colors.teal[900]),

            ),

          ],

        ),

      ),

    );

  }

}

class MainTabs extends StatefulWidget {

  const MainTabs({super.key});

  @override

  State<MainTabs> createState() => _MainTabsState();

}

class _MainTabsState extends State<MainTabs> {

  int _index = 0;

  final List<Widget> _screens = const [

    TempleListScreen(),

    TempleMapScreen(),

    AppSettingsScreen(),

  ];

  BannerAd? _bannerAd;

  @override

  void initState() {

    super.initState();

    _loadBannerAd();

  }

  void _loadBannerAd() {

    final adUnitId = Platform.isAndroid

        ? ‘ca-app-pub-9834322564165763/1171588778’ // Android用テストID

        : ‘ca-app-pub-9834322564165763/2934735716’; // iOS用テストID

    _bannerAd = BannerAd(

      adUnitId: adUnitId,

      size: AdSize.banner,

      request: const AdRequest(),

      listener: BannerAdListener(

        onAdLoaded: (ad) => setState(() {}),

        onAdFailedToLoad: (ad, error) {

          ad.dispose();

        },

      ),

    )..load();

  }

  @override

  void dispose() {

    _bannerAd?.dispose();

    super.dispose();

  }

  @override

  Widget build(BuildContext context) {

    return Scaffold(

      body: Column(

        children: [

          Expanded(child: IndexedStack(index: _index, children: _screens)),

          if (_bannerAd != null)

            Container(

              alignment: Alignment.center,

              width: _bannerAd!.size.width.toDouble(),

              height: _bannerAd!.size.height.toDouble(),

              child: AdWidget(ad: _bannerAd!),

            )

          else

            const SizedBox(height: 50),

        ],

      ),

      bottomNavigationBar: BottomNavigationBar(

        currentIndex: _index,

        onTap: (v) => setState(() => _index = v),

        items: const [

          BottomNavigationBarItem(icon: Icon(Icons.list), label: ‘札所一覧’),

          BottomNavigationBarItem(icon: Icon(Icons.map), label: ‘巡礼マップ’),

          BottomNavigationBarItem(icon: Icon(Icons.info), label: ‘設定・説明’),

        ],

      ),

    );

  }

}

class TempleListScreen extends StatefulWidget {

  const TempleListScreen({super.key});

  @override

  State<TempleListScreen> createState() => _TempleListScreenState();

}

class _TempleListScreenState extends State<TempleListScreen> {

  List<String> visitedIds = [];

  Position? _pos;

  @override

  void initState() {

    super.initState();

    _load();

  }

  _load() async {

    final prefs = await SharedPreferences.getInstance();

    setState(() => visitedIds = prefs.getStringList(‘visited’) ?? []);

    try {

      Position p = await Geolocator.getCurrentPosition();

      setState(() => _pos = p);

    } catch (_) {}

  }

  @override

  Widget build(BuildContext context) {

    return Scaffold(

      appBar: AppBar(title: const Text(‘札所一覧’), backgroundColor: Colors.teal[100]),

      body: ListView.builder(

        itemCount: shikokuTemples.length,

        itemBuilder: (context, i) {

          final t = shikokuTemples[i];

          final isV = visitedIds.contains(t.number.toString());

          double? dist;

          if (_pos != null) {

            dist = Geolocator.distanceBetween(_pos!.latitude, _pos!.longitude, t.lat, t.lng) / 1000;

          }

          return ListTile(

            leading: CircleAvatar(

              backgroundColor: Colors.teal[700],

              foregroundColor: Colors.white,

              child: Text(t.number.toString()),

            ),

            title: Text(t.name),

            subtitle: Text(dist != null ? “${dist.toStringAsFixed(1)} km” : t.address),

            trailing: Icon(isV ? Icons.check_circle : Icons.circle_outlined, color: isV ? Colors.teal : null),

            onTap: () => Navigator.push(

              context,

              MaterialPageRoute(builder: (_) => DetailScreen(temple: t)),

            ).then((_) => _load()),

          );

        },

      ),

    );

  }

}

class TempleMapScreen extends StatefulWidget {

  const TempleMapScreen({super.key});

  @override

  State<TempleMapScreen> createState() => _TempleMapScreenState();

}

class _TempleMapScreenState extends State<TempleMapScreen> {

  List<String> visitedIds = [];

  @override

  void initState() {

    super.initState();

    _load();

  }

  _load() async {

    final prefs = await SharedPreferences.getInstance();

    setState(() => visitedIds = prefs.getStringList(‘visited’) ?? []);

  }

  @override

  Widget build(BuildContext context) {

    return Scaffold(

      appBar: AppBar(title: const Text(‘巡礼マップ’), backgroundColor: Colors.teal[100]),

      body: GoogleMap(

        initialCameraPosition: const CameraPosition(target: LatLng(34.1601, 134.5028), zoom: 8),

        myLocationEnabled: true,

        markers: shikokuTemples.map((t) {

          final isV = visitedIds.contains(t.number.toString());

          return Marker(

            markerId: MarkerId(t.number.toString()),

            position: LatLng(t.lat, t.lng),

            icon: BitmapDescriptor.defaultMarkerWithHue(

              isV ? BitmapDescriptor.hueGreen : BitmapDescriptor.hueRed,

            ),

            infoWindow: InfoWindow(

              title: t.name,

              snippet: ‘詳細を見る’,

              onTap: () => Navigator.push(

                context,

                MaterialPageRoute(builder: (_) => DetailScreen(temple: t)),

              ).then((_) => _load()),

            ),

          );

        }).toSet(),

      ),

    );

  }

}

class DetailScreen extends StatefulWidget {

  final Temple temple;

  const DetailScreen({super.key, required this.temple});

  @override

  State<DetailScreen> createState() => _DetailScreenState();

}

class _DetailScreenState extends State<DetailScreen> {

  File? _landscapeImage;

  File? _goshuinImage;

  final TextEditingController _memoController = TextEditingController();

  bool _isVisited = false;

  @override

  void initState() {

    super.initState();

    _loadData();

  }

  _loadData() async {

    final prefs = await SharedPreferences.getInstance();

    final visited = prefs.getStringList(‘visited’) ?? [];

    final landPath = prefs.getString(‘photo_${widget.temple.number}’);

    final goshuinPath = prefs.getString(‘goshuin_${widget.temple.number}’);

    setState(() {

      _isVisited = visited.contains(widget.temple.number.toString());

      _memoController.text = prefs.getString(‘memo_${widget.temple.number}’) ?? “”;

      if (landPath != null && File(landPath).existsSync()) _landscapeImage = File(landPath);

      if (goshuinPath != null && File(goshuinPath).existsSync()) _goshuinImage = File(goshuinPath);

    });

  }

  _saveMemo() async {

    final prefs = await SharedPreferences.getInstance();

    await prefs.setString(‘memo_${widget.temple.number}’, _memoController.text);

    if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text(“メモを保存しました”)));

  }

  _toggleVisited() async {

    final prefs = await SharedPreferences.getInstance();

    List<String> visited = prefs.getStringList(‘visited’) ?? [];

    String id = widget.temple.number.toString();

    _isVisited ? visited.remove(id) : visited.add(id);

    await prefs.setStringList(‘visited’, visited);

    setState(() => _isVisited = !_isVisited);

  }

  Future<void> _takePhoto(bool isGoshuin) async {

    final scaffoldMessenger = ScaffoldMessenger.of(context);

    final source = await showModalBottomSheet<ImageSource>(

      context: context,

      backgroundColor: Colors.white,

      shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))),

      builder: (BuildContext sheetCtx) {

        return SafeArea(

          child: Column(

            mainAxisSize: MainAxisSize.min,

            children: <Widget>[

              const Padding(

                padding: EdgeInsets.all(16.0),

                child: Text(‘写真の追加方法’, style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),

              ),

              ListTile(

                leading: const Icon(Icons.photo_camera, color: Colors.teal),

                title: const Text(‘カメラで撮影する’),

                onTap: () => Navigator.pop(sheetCtx, ImageSource.camera),

              ),

              ListTile(

                leading: const Icon(Icons.photo_library, color: Colors.teal),

                title: const Text(‘アルバムから選ぶ’),

                onTap: () => Navigator.pop(sheetCtx, ImageSource.gallery),

              ),

            ],

          ),

        );

      },

    );

    if (source == null) return;

    try {

      final picker = ImagePicker();

      final pickedFile = await picker.pickImage(source: source, maxWidth: 1000);

      if (pickedFile == null) return;

      final dir = await getApplicationDocumentsDirectory();

      final fileName = isGoshuin ? ‘goshuin_${widget.temple.number}.jpg’ : ‘land_${widget.temple.number}.jpg’;

      final savedFile = await File(pickedFile.path).copy(p.join(dir.path, fileName));

      final prefs = await SharedPreferences.getInstance();

      await prefs.setString(isGoshuin ? ‘goshuin_${widget.temple.number}’ : ‘photo_${widget.temple.number}’, savedFile.path);

      if (!mounted) return;

      setState(() {

        if (isGoshuin) {

          _goshuinImage = savedFile;

        } else {

          _landscapeImage = savedFile;

        }

      });

    } catch (e) {

      if (!mounted) return;

      scaffoldMessenger.showSnackBar(SnackBar(content: Text(“画像の取得に失敗しました: $e”)));

    }

  }

  @override

  Widget build(BuildContext context) {

    return Scaffold(

      appBar: AppBar(title: Text(widget.temple.name)),

      body: SingleChildScrollView(

        child: Column(

          children: [

            SizedBox(

              height: 200,

              child: GoogleMap(

                initialCameraPosition: CameraPosition(target: LatLng(widget.temple.lat, widget.temple.lng), zoom: 15),

                markers: {Marker(markerId: const MarkerId(‘temple_loc’), position: LatLng(widget.temple.lat, widget.temple.lng))}

              ),

            ),

            Padding(

              padding: const EdgeInsets.all(16),

              child: Column(

                crossAxisAlignment: CrossAxisAlignment.start,

                children: [

                  const Text(“【寺院解説】”, style: TextStyle(fontWeight: FontWeight.bold)),

                  const SizedBox(height: 4),

                  Text(widget.temple.description),

                  const SizedBox(height: 20),

                  _imageSection(“風景写真”, _landscapeImage, () => _takePhoto(false)),

                  const SizedBox(height: 20),

                  _imageSection(“御朱印”, _goshuinImage, () => _takePhoto(true), isGoshuin: true),

                  const SizedBox(height: 20),

                  const Text(“【参拝メモ】”, style: TextStyle(fontWeight: FontWeight.bold)),

                  const SizedBox(height: 8),

                  TextField(controller: _memoController, maxLines: 3, decoration: const InputDecoration(border: OutlineInputBorder())),

                  const SizedBox(height: 8),

                  ElevatedButton(onPressed: _saveMemo, child: const Text(“メモを保存”)),

                  const Divider(height: 40),

                  Center(

                    child: ElevatedButton.icon(

                      onPressed: _toggleVisited,

                      icon: Icon(_isVisited ? Icons.check : Icons.add),

                      label: Text(_isVisited ? “訪問済み” : “訪問を記録する”),

                      style: ElevatedButton.styleFrom(backgroundColor: _isVisited ? Colors.teal[100] : null),

                    ),

                  ),

                  const SizedBox(height: 60),

                ],

              ),

            ),

          ],

        ),

      ),

    );

  }

  Widget _imageSection(String title, File? image, VoidCallback onAction, {bool isGoshuin = false}) {

    return Column(

      crossAxisAlignment: CrossAxisAlignment.start,

      children: [

        Text(“【$title】”, style: const TextStyle(fontWeight: FontWeight.bold)),

        const SizedBox(height: 8),

        GestureDetector(

          behavior: HitTestBehavior.opaque,

          onTap: onAction,

          child: image != null

              ? ClipRRect(borderRadius: BorderRadius.circular(10), child: Image.file(image, height: 180, width: double.infinity, fit: BoxFit.cover))

              : Container(

                  height: 120,

                  width: double.infinity,

                  decoration: BoxDecoration(

                    color: isGoshuin ? Colors.red[50] : Colors.grey[200],

                    borderRadius: BorderRadius.circular(10),

                    border: Border.all(color: Colors.grey[300]!),

                  ),

                  child: Column(

                    mainAxisAlignment: MainAxisAlignment.center,

                    children: [

                      Icon(Icons.camera_alt, color: isGoshuin ? Colors.red[300] : Colors.grey[600], size: 36),

                      const SizedBox(height: 4),

                      Text(“タップして$titleを追加”, style: TextStyle(color: Colors.grey[700], fontSize: 13)),

                    ],

                  ),

                ),

        ),

        const SizedBox(height: 4),

        TextButton.icon(

          onPressed: onAction,

          icon: const Icon(Icons.add_a_photo, size: 18),

          label: Text(“$titleを追加・変更”),

        ),

      ],

    );

  }

}

class AppSettingsScreen extends StatelessWidget {

  const AppSettingsScreen({super.key});

  @override

  Widget build(BuildContext context) {

    return Scaffold(

      appBar: AppBar(title: const Text(“設定・ガイド”), backgroundColor: Colors.teal[100]),

      body: ListView(

        children: [

          ListTile(

            leading: const Icon(Icons.help_center_outlined, color: Colors.teal),

            title: const Text(“アプリの使い方・巡礼ガイド”),

            subtitle: const Text(“各機能の詳細と参拝の心得”),

            trailing: const Icon(Icons.arrow_forward_ios, size: 16),

            onTap: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const AppGuidePage())),

          ),

          const Divider(),

          const ListTile(

            leading: Icon(Icons.info_outline),

            title: Text(“バージョン情報”),

            subtitle: Text(“1.0.0”),

          ),

        ],

      ),

    );

  }

}

class AppGuidePage extends StatelessWidget {

  const AppGuidePage({super.key});

  @override

  Widget build(BuildContext context) {

    return Scaffold(

      appBar: AppBar(title: const Text(“アプリの使い方”), backgroundColor: Colors.teal[100]),

      body: SingleChildScrollView(

        padding: const EdgeInsets.all(24),

        child: Column(

          crossAxisAlignment: CrossAxisAlignment.start,

          children: [

            const Text(“四国八十八ヶ所 巡礼の旅へ”, style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.teal)),

            const SizedBox(height: 16),

            const Text(“このアプリは、弘法大師ゆかりの四国八十八ヶ所霊場を巡る皆様をサポートするために開発されました。\n巡礼の足跡を手軽に記録し、自分だけのデジタル納経帳としてご活用ください。”, style: TextStyle(height: 1.6)),

            const SizedBox(height: 24),

            const Text(“【主な機能】”, style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),

            const SizedBox(height: 8),

            const Text(“・札所一覧と現在地からの距離表示\n・巡礼マップによる位置把握\n・参拝記録とメモ・写真(風景・御朱印)の保存”),

            const SizedBox(height: 40),

            Center(child: ElevatedButton(onPressed: () => Navigator.pop(context), child: const Text(“戻る”))),

          ],

        ),

      ),

    );

  }

}

💻 Info.plist フルソースコード

iOSアプリを公開するためには、設定ファイルである Info.plist にカメラや位置情報などの「利用目的」を日本語で書かないと即座にリジェクトされます。以下のコードはパーミッション、iPad全画面対応(黒帯防止)、ローカルネットワーク許可、AdMob設定をすべて網羅した完全版です。

XML

<?xml version=”1.0″ encoding=”UTF-8″?>

<!DOCTYPE plist PUBLIC “-//Apple//DTD PLIST 1.0//EN” “http://www.apple.com/DTDs/PropertyList-1.0.dtd”>

<plist version=”1.0″>

<dict>

   <key>CFBundleDevelopmentRegion</key>

   <string>$(DEVELOPMENT_LANGUAGE)</string>

   <key>CFBundleDisplayName</key>

   <string>四国巡礼88</string>

   <key>CFBundleExecutable</key>

   <string>$(EXECUTABLE_NAME)</string>

   <key>CFBundleIdentifier</key>

   <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>

   <key>CFBundleInfoDictionaryVersion</key>

   <string>6.0</string>

   <key>CFBundleName</key>

   <string>shikoku88_app</string>

   <key>CFBundlePackageType</key>

   <string>APPL</string>

   <key>CFBundleShortVersionString</key>

   <string>$(FLUTTER_BUILD_NAME)</string>

   <key>CFBundleSignature</key>

   <string>????</string>

   <key>CFBundleVersion</key>

   <string>$(FLUTTER_BUILD_NUMBER)</string>

   <key>LSRequiresIPhoneOS</key>

   <true/>

   <key>UILaunchStoryboardName</key>

   <string>LaunchScreen</string>

   <key>UIMainStoryboardFile</key>

   <string>Main</string>

   <key>UISupportedInterfaceOrientations</key>

   <array>

      <string>UIInterfaceOrientationPortrait</string>

      <string>UIInterfaceOrientationLandscapeLeft</string>

      <string>UIInterfaceOrientationLandscapeRight</string>

   </array>

   <key>UISupportedInterfaceOrientations~ipad</key>

   <array>

      <string>UIInterfaceOrientationPortrait</string>

      <string>UIInterfaceOrientationPortraitUpsideDown</string>

      <string>UIInterfaceOrientationLandscapeLeft</string>

      <string>UIInterfaceOrientationLandscapeRight</string>

   </array>

   <!– iPadでの黒帯エラー防止 –>

   <key>UIRequiresFullScreen</key>

   <true/>

   <key>CADisableMinimumFrameDurationOnPhone</key>

   <true/>

   <key>UIApplicationSupportsIndirectInputEvents</key>

   <true/>

   <!– 各種パーミッションの目的文字列(必須) –>

   <key>NSCameraUsageDescription</key>

   <string>巡礼の記録として写真を撮影するためにカメラを使用します。</string>

   <key>NSPhotoLibraryUsageDescription</key>

   <string>巡礼の記録として保存した写真を選択するために写真ライブラリにアクセスします。</string>

   <key>NSMicrophoneUsageDescription</key>

   <string>動画撮影用にマイクにアクセスします。</string>

   <key>NSLocationWhenInUseUsageDescription</key>

   <string>現在地を取得し、各寺院までの距離を表示するために位置情報を使用します。</string>

   <key>NSLocalNetworkUsageDescription</key>

   <string>アプリのデバッグおよびデバイス間の通信のためにローカルネットワークを使用します。</string>

   <key>NSBonjourServices</key>

   <array>

      <string>_dartobservatory._tcp</string>

   </array>

   <key>LSApplicationQueriesSchemes</key>

   <array>

      <string>googlechromes</string>

      <string>comgooglemaps</string>

      <string>https</string>

      <string>http</string>

   </array>

   <!– AdMobアプリID –>

   <key>GADApplicationIdentifier</key>

   <string>ca-app-pub-3940256099942544~1458002511</string>

</dict>

</plist>

第4章:アプリを成長させる!シリーズ化への道(応用編)

「西国三十三所」のアプリが完成すれば、そのシステム(地図表示、写真保存、距離計算)は他の巡礼アプリにも100%使い回すことができます。私はこれを「関西花の寺二十五霊場」や「四国八十八ヶ所巡礼」へと横展開(シリーズ化)しました。

1. 横展開を成功させる「一括置換」のテクニック

コード内の文字を一つずつ手で直すのは大変です。Android Studioの「一括置換(Replace in Files)」機能(Ctrl+Shift+R)を使えば、一瞬で安全に書き換えることができます。

  • パターン1(クラス名やアプリ名): Saigoku ➔ Shikoku
  • パターン2(変数名やデータ名): saigoku ➔ shikoku
  • パターン3(日本語テキスト): 西国三十三所 ➔ 四国八十八ヶ所

2. プロジェクトを独立させる必須作業

全く別のアプリとしてストアに公開するには、以下の変更が必須です。

  • パッケージ名の変更: android/app/build.gradle 内の applicationId を、com.wzero7.saigoku から com.wzero7.shikoku88 などに変更します。これを行わないと「同じアプリのアップデート」とみなされてしまいます。
  • アイコンとテーマの変更: 花の寺ならピンク色、四国なら緑色など、視覚的に全く別のアプリに見えるようにテーマカラーとアイコン画像(flutter_launcher_iconsを使用)を差し替えます。

おわりに:78歳からの挑戦を振り返って

開発の道のりは決して平坦ではなく、真っ赤なエラーメッセージに心が折れそうになることもありました。しかし、エラーはプログラムが「ここを直せば動くよ!」と教えてくれる「道しるべ」だと気づいたとき、パズルを解くような知的な喜びに変わりました。

自分が書いたコードがスマートフォンの中で「本物のアプリ」として動き出したときの感動は、年齢に関係なくモノづくりの原点だと感じます。「プログラミングは若者のもの」という先入観は捨ててください。少しの好奇心があれば、何歳からでも新しい世界は開けます。本書のフルソースコードが、皆様の挑戦のヒントになれば幸いです。

付録:Kindle(電子書籍)で出版するための完全ガイド

連載が完了したら、それらの原稿を一つにまとめて「Kindle(Amazonの電子書籍)」として出版しましょう!

1. 原稿を「本」の形式に整える

Kindle用の原稿は Microsoft Word で作成するのが最も簡単ですが、以下の設定を行わないとページ数が極端に少なくなってしまいます。

  • 用紙サイズの変更: Wordの「レイアウト」タブから、用紙サイズをA4から 「A5」または「B6」 に変更してください。
  • 見出しの設定: 「第1章」などの文字を選択し、Wordの「ホーム」タブにある 「見出し1」「見出し2」 のスタイルを必ず適用してください。KDPがこれを読み取り、自動で目次を作ってくれます。
  • 段落の字下げ: 文章全体を選択し、「段落」設定からインデントの最初の行を 「字下げ(1字)」 にします。スペースキーを使うとスマホでレイアウトが崩れます。
  • 改ページの活用: 章が変わる時は、エンターキーを何度も押すのではなく 「改ページ(Ctrl + Enter)」 を挿入してください。
  • 画像の挿入: スクリーンショット画像をふんだんに挿入してください。画像はページ数を稼ぎ、読者の理解を大いに助けます。

2. KDP(Kindle Direct Publishing)での出版手続き

  1. 表紙の作成: Canvaなどの無料ツールで、縦長(推奨 2560×1600 px)の表紙画像(JPEG)を作成します。
  2. KDPへの登録: Amazon KDPにアクセスし、著者情報や税務情報を登録します。
  3. アップロードと価格設定: Wordファイルと表紙画像をアップロードします。初めての出版なら、Kindle Unlimitedの対象になる「KDPセレクト」に登録し、ロイヤリティ70%(250円〜など)で設定するのがおすすめです。
  4. 審査と公開: 審査完了後、通常24時間程度でAmazonストアにあなたの本が並びます!

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

CAPTCHA