Closed18

モバイルアプリ開発初心者による「豆ログ」の開発 ~FlutterでAndroidアプリ開発~

gotoooogotoooo

はじめに

これまでWebアプリ開発の学習を目的としてコーヒー豆購入履歴管理ツールの開発を試みた。
画面や機能の実装は一通り完了したもののセキュリティ面での懸念が払拭しきれず、運用するまでには至らなかった。

https://zenn.dev/gotoooo/scraps/d93f28561c4b75
https://zenn.dev/gotoooo/scraps/bc0cefc78ef01b
https://zenn.dev/gotoooo/scraps/ab5f4260d43775

そもそもユーザ認証やクラウドでのデータ管理が必須ではない自分専用アプリなので、クローズドなネットワークでの動作を想定したモバイルアプリとしてリベンジしたい。

モバイルアプリ開発にあたって開発言語、フレームワークの選定に迷った。2024年時点で考えられる候補は以下。

  • React Native
  • Flutter
  • Kotlin
  • MAUI

ReactはWebアプリ開発で少し触れたので作法が似ているReact Nativeで進めるのがベターと考えたが、Flutterもクロスプラットフォーム開発のフレームワークとして人気が高いようで、良し悪しを判断する勘所を掴みたいと思い今回はFlutterで進めることとした。

gotoooogotoooo

流れ

  1. 開発環境構築
  2. プロジェクト準備
  3. 実装
  4. テスト
  5. リリース

※すでに開発済みのアプリの移植なので画面設計、機能設計は割愛。

gotoooogotoooo

1.開発環境構築 (VSCode + Flutter SDK + Android Studio)

開発環境

  • Windows10
  • VSCode
  • Flutter SDK
  • AndroidStudio

※iOSは想定しない

開発環境構築手順

Flutter SDKのインストール

公式サイトの手順に従う
https://docs.flutter.dev/get-started/install/windows

  1. Androidを選択

  2. Use VS Code to installを選択

  3. VSCodeを起動し、拡張機能にFlutterをインストール

  4. Ctrl+Shift+Pでコマンドパレットを呼び出し、Flutter: New Projectを実行

  5. Flutter SDKのパスを指定するかダウンロードするか選択するダイアログが表示される。Download SDKを選択する。ダウンロード先のディレクトリは「%USERPROFILE%」直下としておく。

  6. しばらくするとSDKをPATHに追加するか聞かれる。Add SDK to PATHを選択する。

  7. VSCodeで「Which Flutter template?」と聞かれるのでApplicationを選択する。
    フォルダ選択ダイアログが表示されたらプロジェクトを作成したいフォルダを選択する。
    プロジェクト名を聞かれるので適宜プロジェクト名を入力する。

  8. Ctrl+Shift+@でターミナルを開き、flutter doctorコマンドを実行する。
    Android toolchain がインストールされていない旨の警告が表示される。

Android Studioのインストール
  1. 公式サイトからインストーラをダウンロードする
    https://developer.android.com/studio?hl=ja

  2. インストーラを起動し、画面に従ってインストールを進める。

  3. Android Studioを起動し、画面に従ってセットアップを進める。

  4. Command-line Toolsをインストールする
    MoreActionsからSDK Managerを選択し、SDK ToolsタブでAndroid SDK Command-line ToolsにチェックをつけてApplyを押す。

  1. VSCodeのターミナルでAndroid licensesの警告に対処する
    flutter doctorを実行すると下記警告が表示される。
    警告に従い、 "flutter doctor --android-licenses"を実行する

ライセンスに同意するか数回聞かれるので全てyでEnterする。

再度flutter doctorを実行すると上記ライセンスに関する警告が解消されている。

gotoooogotoooo

2.プロジェクト準備

  1. githubでリポジトリ作成
     後述するようにVSCode上でFlutterのプロジェクトを新規作成する際に.gitignoreやREADME.mdが生成される。
    そのため、githubでリポジトリを作成する際はこれらのファイルが生成されないよう設定する。

  2. VSCodeでFlutterプロジェクトを新規作成
     Ctrl+Shift+Pでコマンドパレットを呼び出し、Flutter: New Projectを選択する。
    プロジェクトを生成したいフォルダを選択するダイアログが表示される。所望のフォルダを選択する。
    次にプロジェクト名の入力を求められる。ここで入力した名前でフォルダが生成されその下に.gitignoreやREADME.mdなどの一式が生成される。
    今回のプロジェクト名は「mamelog_mobile」とする。

  3. mamelog_mobileフォルダをgitリポジトリにする
    git init

  4. 1.で作成したリポジトリをリモートリポジトリに設定する
    git remote add origin https://github.com/[User]/mamelog_mobile.git

  5. ファイル一式をgit管理対象に追加してコミットする

  6. リモートリポジトリにプッシュする

gotoooogotoooo

3.実装

作業の流れ

  1. 画面のスケルトンを実装
  2. 画面遷移を実装
  3. モデル層を実装
  4. リポジトリを実装
  5. 各画面を実装
  6. リファクタリング
gotoooogotoooo

3-1.画面のスケルトンを実装

StatelessWidgetを継承させた仮実装のページを用意しておく。

purchase_history_list_page.dart
import 'package:flutter/material.dart';

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

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Mamelog'),
      ),
      body: const Center(
        child: Text('Welcome to the PurchaseHistoryList page!'),
      ),
    );
  }
}

3-2.画面遷移を実装

  • 起動直後の画面
    自動的にメイン画面へ遷移する。
top_page.dart
import 'package:flutter/material.dart';
import 'package:mamelog_mobile/feature/app/page/main_page.dart';

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

  
  State<TopPage> createState() => _TopPageState();
}

class _TopPageState extends State<TopPage> {
  bool _isLoading = false;

  void _onLoaded() {
    setState(() {
      _isLoading = true;
    });

    // TODO: load some data
    Future.delayed(const Duration(seconds: 1), () {
      setState(() {
        _isLoading = false;
      });

      Navigator.push(
        context,
        MaterialPageRoute(builder: (context) => const MainPage(title: 'Mamelog')),
      );
    });
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
        body: Center(
      child: Column(
        children: <Widget>[
          const Text('This is Top Page'),
          if (_isLoading) const Text('Now loading...'),
        ],
      ),
    ));
  }

  
  void initState() {
    super.initState();
    _onLoaded();
  }
}

  • メイン画面
    下部にナビゲーションバーがある。
    PurchaseHistoryListPageなど仮実装したページを切り替える。
main_page.dart
import 'package:flutter/material.dart';
import 'package:mamelog_mobile/feature/brand/page/brand_list_page.dart';
import 'package:mamelog_mobile/feature/production_area/page/production_area_list_page.dart';
import 'package:mamelog_mobile/feature/purchase_history/page/purchase_history_list_page.dart';
import 'package:mamelog_mobile/feature/shop/page/shop_list_page.dart';

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

  
  State<MainPage> createState() => MainPageState();
}

class MainPageState extends State<MainPage> {
  static const _pages = [
    PurchaseHistoryListPage(),
    BrandListPage(),
    ShopListPage(),
    ProductionAreaListPage(),
  ];
  int _selectedIndex = 0;

  void _onItemTapped(int value) {
    setState(() {
      _selectedIndex = value;
    });
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      body: _pages[_selectedIndex],
      bottomNavigationBar: BottomNavigationBar(
          currentIndex: _selectedIndex,
          onTap: _onItemTapped,
          items: const <BottomNavigationBarItem>[
            BottomNavigationBarItem(
                icon: Icon(Icons.note_alt), label: "Mamelog"),
            BottomNavigationBarItem(icon: Icon(Icons.coffee), label: "Brand"),
            BottomNavigationBarItem(
                icon: Icon(Icons.storefront), label: "Shop"),
            BottomNavigationBarItem(icon: Icon(Icons.map), label: "Area"),
          ],
          type: BottomNavigationBarType.fixed),
    );
  }

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


gotoooogotoooo

3-3.モデル層の実装

メモ
const: コンパイル時に値が確定
final: インスタンス生成時に値が確定 <= Domain駆動設計でのValueObjectに類似している

model.dart
class Entity {
  Entity();

  String? id;
  DateTime createAt = DateTime.now();
  DateTime? updatedAt;
  DateTime? deletedAt;
}

class PurchaseHistory extends Entity {
  PurchaseHistory({
    required this.brandName,
    required this.shopName,
    required this.purchaseAt,
  });

  String brandName;
  String shopName;
  DateTime purchaseAt;
  String? imageSource;

  // ファクトリコンストラクタ
  factory PurchaseHistory.createDummy(int index) {
    var item = PurchaseHistory(
      brandName: "dummyBrand$index",
      shopName: "dummyShop$index",
      purchaseAt: DateTime.now(),
    );
    item.id = "$index";

    return item;
  }
}


ユニットテスト
import 'package:flutter_test/flutter_test.dart';
import 'package:mamelog_mobile/common/model/model.dart';

void main() {
  test('result:', (){

    //
    var item = PurchaseHistory(brandName: "testBrandName", shopName: "testShopName", purchaseAt: DateTime(2024, 3, 26, 10, 0, 0));

    //
    expect(item.id, isNull);
    expect(item.createAt, isNotNull);
    expect(item.updatedAt, isNull);
    expect(item.deletedAt, isNull);

    expect(item.brandName, "testBrandName");
    expect(item.shopName, "testShopName");
    expect(item.purchaseAt.year, 2024);
    expect(item.purchaseAt.month, 3);
    expect(item.purchaseAt.day, 26);
    expect(item.purchaseAt.hour, 10);
  });
}

gotoooogotoooo

3-4.リポジトリの実装

UTや動作確認時にデータソースを差し替えられるよう、リポジトリのI/Fを定義し、データソースの実クラスを実装する。
ひとまずメモリでのエンティティ管理リポジトリのみ実装する。

repository.dart
import 'package:mamelog_mobile/common/model/model.dart';

abstract class IRepository<T extends Entity> {
  Future<List<T>> getAllItemsAsync();
  Future<T?> getItemFromIdAsync(String id);
  Future<T> addItemAsync(T item);
  Future<void> updateItemAsync(String id, T item);
  Future<void> deleteItemAsync(String id);
}


in_memory.dart
import 'package:collection/collection.dart';
import 'package:mamelog_mobile/common/model/model.dart';
import 'package:mamelog_mobile/common/model/repository.dart';

abstract class InMemoryRepositoryBase<T extends Entity>
    implements IRepository<T> {
  InMemoryRepositoryBase();
  final List<T> _items = <T>[];

  Future<void> clearItemsAsync() async {
    _items.clear();
    return;
  }

  Future<void> prepareDummyItemsAsync();

  
  Future<T> addItemAsync(T item) async {
    var newId = "${_items.length}";
    item.id = newId;
    _items.add(item);
    return item;
  }

  
  Future<void> deleteItemAsync(String id) async {
    var target = await getItemFromIdAsync(id);
    if (target != null) {
      target.deletedAt = DateTime.now();
      // _items.remove(target);
    }
  }

  
  Future<List<T>> getAllItemsAsync() async {
    var existItems = _items.where((element) => element.deletedAt == null);
    return existItems.toList();
  }

  
  Future<T?> getItemFromIdAsync(String id) async {
    var target = _items.firstWhereOrNull((item) => item.id == id);
    if (target != null && target.deletedAt == null) {
      return target;
    }
    return null;
  }
}


class InMemoryBrandRepository extends InMemoryRepositoryBase<Brand> {
  InMemoryBrandRepository({dummyItems = false}) {
    if (dummyItems){
      prepareDummyItemsAsync();
    }
  }

  
  Future<void> updateItemAsync(String id, Brand item) async {
    var target = await getItemFromIdAsync(id);
    if (target != null) {
      target.name = item.name;
      target.productionAreaName = item.productionAreaName;
      target.productionAreaDetail = item.productionAreaDetail;
      target.annotation = item.annotation;
      target.imageSource = item.imageSource;
      target.updatedAt = DateTime.now();
    }
  }

  
  Future<void> prepareDummyItemsAsync() async {
    var item1 = Brand(name: "brand1");
    var item2 = Brand(name: "brand2");
    await addItemAsync(item1);
    await addItemAsync(item2);
  }
}
gotoooogotoooo

Flutterにおける状態管理

今回はriverpodを使う。
flutter_hooksも併用するのでhooks_riverpodをインストールする。

大まかな使用方法(exampleを要約)

  1. アプリ全体をProvideScopeでラップする
  2. 状態管理したいStateNotifierProviderをグローバルに宣言する
    ※ProviderはいわゆるDIコンテナの役割を果たしている。なので3.で定義する状態通知クラスに限らずリポジトリやViewModelなどもProviderで渡せるように宣言しておくと良さそう。
  3. StateNotifierを継承した状態通知クラスを定義する
  4. 利用側のWidgedはHookConsumerWidgetを継承する形に変更し、bodyの中でHookConsumerを介してグローバルに宣言したproviderの値を参照する。

https://pub.dev/packages/hooks_riverpod

Providerから渡されるインスタンスの生存期間は下記で調整可能。

  • Provider<T>
  • Provider.autoDispose<T>
  • Provider.family<T>

https://note.com/saburo_engineer/n/n59ee8f37263b

参考:
https://qiita.com/taisei_dev/items/4c9d9572a56051a1d51f
https://qiita.com/karamage/items/8d1352e5a4f1b079210b

gotoooogotoooo

3-5.各画面の実装

  • 一覧表示
    予め定義したエンティティの集合の状態を表すProviderを介してリストを参照する。
    初期化処理内で表示用のエンティティリストの状態(filteredItems)を初期化する。
    表示するWidgetはListView.separated()のitemBuilderでfilteredItemsから子Widgetを生成する。
Sample
class PurchaseHistoryListPage extends HookConsumerWidget {
  const PurchaseHistoryListPage({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    // Page横断
    final items = ref.watch(purchaseHistoryListProvider);
    final repo = ref.read(purchaseHistoryRepositoryProvider);
    final listState = ref.read(purchaseHistoryListProvider.notifier);

    // Widget内限定
    final filteredItems = useState<List<PurchaseHistory>>([]);
    final searchText = useState<String?>("");

    useEffect(() {
      filteredItems.value = ref.read(purchaseHistoryListProvider);
    }, []);

    useEffect(() {
      if (searchText.value == null || searchText.value!.isEmpty) {
        filteredItems.value = items;
      } else {
        filteredItems.value = items
            .where((item) => item.brandName.contains(searchText.value!))
            .toList();
      }
    }, [items, searchText.value]);
...
}
子Widget生成部
Expanded(
              child: ListView.separated(
                padding: const EdgeInsets.only(top: 8.0),
                itemCount: filteredItems.value.length,
                itemBuilder: (context, index) {
                  final item = filteredItems.value[index];
                  return EntityListItem(
                      id: item.id!,
                      header: item.shopName,
                      title: item.brandName,
                      subTitle: convertDateFormat(item.purchaseAt),
                      onSelect: handleSelectItem,
                      onDelete: handleDeleteItem,
                      onEdit: handleEditItem);
                },
                separatorBuilder: (context, index) {
                  return const Divider(height: 0.5);
                },
              ),
            )
  • 自作Formダイアログを呼び出し、結果を呼び出し元で利用する
呼び出し元 抜粋

    void handleAddItem() async {
      // show dialog
      var result = await showDialog<PurchaseHistory>(
        context: context,
        builder: (context) => const PurchaseHistoryFormDialog(),
      );
      if (result != null) {
        await repo.addItemAsync(result).then((created) {
          listState.add(created);
        });
      }
    }

ダイアログ側 抜粋

class PurchaseHistoryFormDialog extends HookConsumerWidget {
  const PurchaseHistoryFormDialog({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final formKey = GlobalKey<FormState>();
    final purchaseDate = useState<DateTime?>(DateTime.now());
    final brandName = useState<String?>(null);
    final shopName = useState<String?>(null);
    final annotation = useState<String>("");

    final brands = ref.watch(brandListProvider);
    final shops = ref.watch(shopListProvider);

    void handleSubmit() {
      Navigator.pop<PurchaseHistory>(
          context,
          // 呼び出し元に返すデータ
          PurchaseHistory(
              brandName: brandName.value!,
              shopName: shopName.value!,
              purchaseAt: purchaseDate.value!));
    }
  • Formのバリデーション
    flutter_form_builderを使う。
    WidgetのTextFormFieldなどのvlaidatorにFormBuilderValidators.required(errorText: "required");を指定して、OKボタン押下時のハンドラ内でformKey.currentState?.validate(); でチェックする。
Sample
...
import 'package:flutter_form_builder/flutter_form_builder.dart';
import 'package:form_builder_validators/form_builder_validators.dart';

class PurchaseHistoryFormDialog extends HookConsumerWidget {
  const PurchaseHistoryFormDialog({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final formKey = GlobalKey<FormBuilderState>();
    final purchaseDate = useState<DateTime?>(DateTime.now());
    final brandName = useState<String?>(null);
    final shopName = useState<String?>(null);
    final annotation = useState<String>("");

    final brands = ref.watch(brandListProvider);
    final shops = ref.watch(shopListProvider);

    void handleSubmit() {
      // バリデーション
      var isValid = formKey.currentState?.validate();
      if (isValid == true) {
        Navigator.pop<PurchaseHistory>(
            context,
            // 呼び出し元に返すデータ
            PurchaseHistory(
                brandName: brandName.value!,
                shopName: shopName.value!,
                purchaseAt: purchaseDate.value!));
      }
    }
...
}
{
...
    return AlertDialog(
      title: const Text("Add"),
      content: FormBuilder(
          key: formKey,
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              // 日付選択
              TextFormField(
                decoration: const InputDecoration(
                    suffixIcon: Icon(Icons.calendar_month),
                    labelText: "Purchase Date",
                    hintText: "Select Date"),
                onTap: () {
                  showDatePicker(
                    context: context,
                    initialDate: purchaseDate.value ?? DateTime.now(),
                    firstDate: DateTime(1900),
                    lastDate: DateTime(2100),
                  ).then((pickedDate) {
                    if (pickedDate != null) {
                      purchaseDate.value = pickedDate;
                    }
                  });
                },
                keyboardType: TextInputType.datetime,
                readOnly: true,
                initialValue: convertDateFormat(purchaseDate.value),
                validator: FormBuilderValidators.required(errorText: "reqired"),
              ),
...
}

https://qiita.com/urbsny/items/2ddb43d18fc13a9b4aad

gotoooogotoooo

Tips

ギャラリーから画像を選択して表示する

  1. Widgetを配置
sample
import 'package:flutter/material.dart';

class LeadingImage extends StatelessWidget {
  final String? imageSource;
  final double width;
  final double height;

  const LeadingImage({
    super.key,
    this.imageSource,
    this.width = 48,
    this.height = 48,
  });

  
  Widget build(BuildContext context) {
    return SizedBox(
      width: width,
      height: height,
      child: ClipRRect(
        borderRadius: BorderRadius.circular(8),
        child: imageSource != null && imageSource!.isNotEmpty
            ? Image.network(imageSource!, fit: BoxFit.cover)
            : Container(color: Colors.grey),
      ),
    );

  }
}

    return AlertDialog(
      title: Text(dialogTitle),
      content: FormBuilder(
          key: formKey,
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              // 画像
              Row(
                children: [
                  LeadingImage(
                    width: 64,
                    height: 64,
                    imageSource: imageSource.value,
                  ),
                  IconButton(
                      onPressed: handleEditImageButtonPressed,
                      icon: const Icon(Icons.edit))
                ],
              ),
...
}
  1. 画像選択用のボタンのonPresedで画像選択処理を実装
    下記ライブラリを予めインストールする
  • image_picker
  • path_provider

※Edgeブラウザでのデバッグだとローカルのファイルシステムへのファイルコピーに失敗する。
ひとまずImagePickerで選択したファイルパスそのものの画像を表示することとしている

sample

class ProductionAreaFormDialog extends HookConsumerWidget {
  const ProductionAreaFormDialog({super.key, this.targetItem});
  final ProductionArea? targetItem;

  
  Widget build(BuildContext context, WidgetRef ref) {
...
    final imageSource = useState<String?>(targetItem?.imageSource);
    final picker = ImagePicker();
...

    void handleEditImageButtonPressed() async {
      try {
        var pickedFile = await picker.pickImage(source: ImageSource.gallery);
        if (pickedFile == null) {
          return;
        }
        // temp
        imageSource.value = pickedFile.path;

        final appDirectory = await getApplicationDocumentsDirectory();
        final cacheImageDirectoryPath = path.join(
            appDirectory.path,
            GlobalConstants.cacheDirectoryName,
            GlobalConstants.cacheImageDirectoryName);

        final fileName = path.basename(pickedFile.path);
        final savedImagePath = path.join(cacheImageDirectoryPath, fileName);
        final savedImage = await File(pickedFile.path).copy(savedImagePath);
        imageSource.value = savedImage.path;
      } catch (error) {
        print(error);
      }
    }
...
}

結果

gotoooogotoooo

3-4.リポジトリを実装(本番用)

SQLiteを使用する。
FlutterでSQLiteを扱うためのライブラリとしてsqfliteがあり、こちらを使う。

アプリ内のレイヤー構造はざっくり以下の設計としている。

諸々のPage

リポジトリ

DBヘルパー

sqflite.諸々のAPI

テーブルごとに異なる、共通なもの をうまく整理し、修正時の変更漏れを抑制したい。
以下のように共通なものを集約するクラスを用意した。

DBヘルパー層sample
sqlite_db_schema.dart
import 'package:mamelog_mobile/common/model/model.dart';

class SqliteDBSchemaV1 {
  static const int version = 1;
  static const String internalIdColumn = "id";
  static const String entityIdColumn = "entityId";
  static const String createdAtColumn = "creataedAt";
  static const String updatedAtColumn = "updatedAt";
  static const String deletedAtColumn = "deletedAt";
  static final Map<Type, String> tableNameMap = {
    Impression: "impression",
    PurchaseHistory: "purchaseHistory",
    Brand: "brand",
    Shop: "shop",
    ProductionArea: "productionArea",
  };

  static final Map<Type, String> tableCreateQuery = {
    PurchaseHistory: '''
CREATE TABLE IF NOT EXISTS ${tableNameMap[PurchaseHistory]} (
  $internalIdColumn INTEGER PRIMARY KEY,
  $entityIdColumn TEXT NOT NULL,
  brandName TEXT NOT NULL,
  shopName TEXT NOT NULL,
  purchaseAt TEXT NOT NULL,
  annotation TEXT,
  imageSource TEXT,
  $createdAtColumn TEXT NOT NULL,
  $updatedAtColumn TEXT,
  $deletedAtColumn TEXT
);
''',
    Brand: '''
CREATE TABLE IF NOT EXISTS ${tableNameMap[Brand]} (
  $internalIdColumn INTEGER PRIMARY KEY,
  $entityIdColumn TEXT NOT NULL,
  name TEXT NOT NULL,
  productionAreaName TEXT,
  productionAreaDetail TEXT,
  annotation TEXT,
  imageSource TEXT,
  $createdAtColumn TEXT NOT NULL,
  $updatedAtColumn TEXT,
  $deletedAtColumn TEXT
);
''',
 ...
}

sqlite_helper.dart
import 'package:mamelog_mobile/common/infrastructure/sqlite_db_schema.dart';
import 'package:mamelog_mobile/common/model/model.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';

class DatabaseHelper {
  static const _databaseName = "database.db";
  static const _databaseVersion = SqliteDBSchemaV1.version;

  DatabaseHelper._privateConstructor();
  static final DatabaseHelper instance = DatabaseHelper._privateConstructor();

  static Database? _database;
  Future<Database> get database async => _database ??= await _initDatabase();

  _initDatabase() async {
    String path = join(await getDatabasesPath(), _databaseName);
    return await openDatabase(path,
        version: _databaseVersion, onCreate: _onCreate);
  }

  Future _onCreate(Database db, int version) async {
    final batch = db.batch();
    batch.execute('DROP TABLE IF EXISTS $_databaseName');
    batch.execute(SqliteDBSchemaV1.tableCreateQuery[ProductionArea]!);
    batch.execute(SqliteDBSchemaV1.tableCreateQuery[Shop]!);
    batch.execute(SqliteDBSchemaV1.tableCreateQuery[Brand]!);
    batch.execute(SqliteDBSchemaV1.tableCreateQuery[PurchaseHistory]!);
    batch.execute(SqliteDBSchemaV1.tableCreateQuery[Impression]!);
    await batch.commit();
  }

  // データ挿入
  Future<int> insert(String tableName, Map<String, dynamic> row) async {
    row[SqliteDBSchemaV1.createdAtColumn] = DateTime.now().toIso8601String();
    var db = await instance.database;
    return await db.insert(tableName, row);
  }

  // 全データ取得
  Future<List<Map<String, dynamic>>> queryAllRows(String tableName,
      {String? where, List<Object>? whereArgs}) async {
    var db = await instance.database;
    if (where != null && whereArgs != null && whereArgs.isNotEmpty) {
      var concatWhere = '$where AND ${SqliteDBSchemaV1.deletedAtColumn} IS NULL';
      var concatWhereArgs = [...whereArgs];
      return await db.query(tableName,
          where: concatWhere, whereArgs: concatWhereArgs);
    } else {
      return await db.query(tableName,
          where: '${SqliteDBSchemaV1.deletedAtColumn} IS NULL');
    }
  }

  // IDによるデータ取得
  Future<Map<String, dynamic>> querySingleRow(
      String tableName, String entityId) async {
    var db = await instance.database;
    var results = await db.query(tableName,
        where:
            '${SqliteDBSchemaV1.deletedAtColumn} IS NULL AND ${SqliteDBSchemaV1.entityIdColumn} = ?',
        whereArgs: [entityId]);
    if (results.isNotEmpty) {
      return results.first;
    } else {
      throw Exception('Item with id $entityId not found');
    }
  }

  // IDによるデータ削除
  Future<int> delete(String tableName, String entityId) async {
    var db = await instance.database;
    var target = await querySingleRow(tableName, entityId);
    target[SqliteDBSchemaV1.deletedAtColumn] = DateTime.now().toIso8601String();
    return await db.update(tableName, target,
        where: '${SqliteDBSchemaV1.entityIdColumn} = ?', whereArgs: [entityId]);
    // return await db.delete(tableName, where: 'id = ?', whereArgs: [id]);
  }

  // IDによるデータ更新
  Future<int> update(
      String tableName, String entityId, Map<String, dynamic> row) async {
    row[SqliteDBSchemaV1.updatedAtColumn] = DateTime.now().toIso8601String();
    var db = await instance.database;
    return await db.update(tableName, row,
        where: '${SqliteDBSchemaV1.entityIdColumn} = ?', whereArgs: [entityId]);
  }
}

リポジトリ層では上記の仕組みを利用し、DB設計都合で決まる文字列のタイプミスなどを防ぐ。
また、エンティティの種別で共通なものは極力共通化させることとした。
どうしてもエンティティ固有のファクトリメソッドは共通化できず、今後の課題である。
おそらくfreezedなどのライブラリを使えば解決するのだろう。

リポジトリ層sample
model.dart
import 'package:mamelog_mobile/common/infrastructure/sqlite_db_schema.dart';

abstract class Entity {
  Entity({this.entityId});

  String? entityId;
  DateTime createdAt = DateTime.now();
  DateTime? updatedAt;
  DateTime? deletedAt;

  Map<String, dynamic> toMap() {
    return {
      SqliteDBSchemaV1.entityIdColumn: entityId,
      SqliteDBSchemaV1.createdAtColumn: createdAt.toIso8601String(),
      SqliteDBSchemaV1.updatedAtColumn: updatedAt?.toIso8601String(),
      SqliteDBSchemaV1.deletedAtColumn: deletedAt?.toIso8601String(),
    };
  }

  factory Entity.fromMap(Map<String, dynamic> row) {
    throw UnimplementedError();
  }
}

class Brand extends Entity {
  Brand(
      {required this.name,
      this.productionAreaName,
      this.productionAreaDetail,
      this.annotation,
      this.imageSource});

  String name;
  String? productionAreaName;
  String? productionAreaDetail;
  String? annotation;
  String? imageSource;

  
  Map<String, dynamic> toMap() {
    var map = super.toMap();
    map['name'] = name;
    map['productionAreaName'] = productionAreaName;
    map['productionAreaDetail'] = productionAreaDetail;
    map['annotation'] = annotation;
    map['imageSource'] = imageSource;
    return map;
  }

  
  factory Brand.fromMap(Map<String, dynamic> map) {
    var item = Brand(
      name: map['name'],
      productionAreaName: map['productionAreaName'],
      productionAreaDetail: map['productionAreaDetail'],
      annotation: map['annotation'],
      imageSource: map['imageSource'],
    );
    item.entityId = map[SqliteDBSchemaV1.entityIdColumn];
    item.createdAt = DateTime.parse(map[SqliteDBSchemaV1.createdAtColumn]);
    item.updatedAt = map[SqliteDBSchemaV1.updatedAtColumn] != null
        ? DateTime.parse(map[SqliteDBSchemaV1.updatedAtColumn])
        : null;
    item.deletedAt = map[SqliteDBSchemaV1.deletedAtColumn] != null
        ? DateTime.parse(map[SqliteDBSchemaV1.deletedAtColumn])
        : null;
    return item;
  }
}
sqlite_repository.dart
import 'package:mamelog_mobile/common/infrastructure/sqlite_helper.dart';
import 'package:mamelog_mobile/common/model/model.dart';
import 'package:mamelog_mobile/common/model/repository.dart';
import 'package:uuid/uuid.dart';

abstract class SqliteRepositoryBase<T extends Entity>
    implements IRepository<T> {
  SqliteRepositoryBase({required this.tableName, required this.dbHelper});

  final String tableName;
  final DatabaseHelper dbHelper;

  
  Future<void> deleteItemAsync(String id) async {
    await dbHelper.delete(tableName, id);
  }

  
  Future<T> addItemAsync(T item) async {
    // ここでentityIdを付与
    var uuid = const Uuid();
    item.entityId = uuid.v4();

    var row = item.toMap();
    var _ = await dbHelper.insert(tableName, row);
    return item;
  }
}

class SqliteBrandRepository extends SqliteRepositoryBase<Brand> {
  SqliteBrandRepository({required super.tableName, required super.dbHelper}) {}

  
  Future<Brand?> updateItemAsync(String id, Brand item) async {
    var row = item.toMap();
    var _ = await dbHelper.update(tableName, id, row);
    var updatedRow = await dbHelper.querySingleRow(tableName, id);
    return Brand.fromMap(updatedRow);
  }

  
  Future<List<Brand>> getAllItemsAsync() async {
    var results = await dbHelper.queryAllRows(tableName);
    return results.map((row) => Brand.fromMap(row)).toList() ?? [];
  }

  
  Future<Brand?> getItemFromIdAsync(String id) async {
    var row = await dbHelper.querySingleRow(tableName, id);
    return Brand.fromMap(row);
  }
}

参考:
https://zenn.dev/pipipi09/articles/217faf2ace3763#sqflite

gotoooogotoooo

4.テスト

Web版でのテスト

DBを使わないInMemoryのリポジトリをつかって画面遷移や表示周りのテストを行う。
機能設計を割愛したため、テスト設計も割愛。
※今回使用したSqfliteがWeb版に対応しておらず、DB操作までテストできなかった。ライブラリ選定を改めたい。

Android版仮想デバイスでのテスト

Web版では実施できなかったSQLiteのDB操作を重点的にテストを行う。
詳細は割愛するがSQL文のタイプミスや非NULL許容にも関わらずNullな値を書き込もうとするなどかなりのバグを検出できた。
エラー内容は初見だと瞬時に理解するのが難しいため、ChatGPTに問い合わせて原因と修正案を提示してもらいながら修正を進めた。

gotoooogotoooo

5.リリース

ひととおり形になったのでAndroidアプリとしてリリースを試みた。
が、しかし、本格的にGoogle Storeでアプリを配布するとなると開発者の情報を公開する必要があることが判明した。
AndroidStudioを使ってパッケージを出力して端末上にインストールする方法もあるようだが、もはやそこまでして今回のアプリを使用するほどの必然性を感じていないため、リリースまで実施せずクローズすることとした。
一応実機デバッグまで進めたのでスクショを貼っておく。

gotoooogotoooo

雑感

  • Flutterに限ったことではないがモバイルアプリ特有の画面遷移に学習を要した。
  • アイコンやテーマなどUIに関するプロパティがデフォルトで用意されており、UIの作り込みは効率的に進められた。
  • WebアプリのReactの使用経験があったため、flutter_hooksのおかげで状態管理、更新はそつなく扱えた。
  • C#やTypeScriptの使用経験があったため、Dart言語の習得はほとんど学習コストがかからなかった。
  • Flutterは利用ユーザ数が多いためか、Web上の情報が豊富である一方玉石混交であり、自分が欲している情報を適切に探すのに注意を要する。
  • Reactの経験が生きたので約1週間でひととおり機能するところまで仕上げることができた。とはいえGlideだと半日だったので、やはりローコードツールはあなどれない。
このスクラップは1ヶ月前にクローズされました