0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ぽよぽよチェス|Clean Architectureで実装するSQLite履歴管理

Last updated at Posted at 2025-07-13

Flutterで開発しているチェスアプリ「ぽよぽよチェス」の「履歴管理」機能について、Clean Architectureに基づいた実装と構成を整理しました。

データ流れの概覧

依存関係図

ディレクトリ構成

lib/
│
├── data/
│   ├── datasources/
│   │   └── local/
│   │       ├── database_helper.dart       # DBの初期化と管理
│   │       └── game_history_table.dart    # テーブル構造の定数定義
│   ├── repositories/
│   │   ├── base_repository.dart           # insert/updateなど共通操作の提供
│   │   └── game_history_repository.dart   # 実際のリポジトリ実装
│
├── domain/
│   ├── entities/
│   │   └── game_record.dart               # GameRecordエンティティ
│   ├── repositories/
│   │   └── igame_history_repository.dart  # リポジトリインターフェース
│
├── application/
│   └── usecases/
│       ├── get_all_game_records.dart      # ユースケース1:すべての記録を取得
│       └── get_game_record_by_id.dart     # ユースケース2:IDで記録を取得
│
├── presentation/
│   ├── viewmodels/
│   │   └── game_history_view_model.dart   # UIと連携するViewModel
│   ├── screens/
│   │   ├── game_history_screen.dart       # 一覧画面
│   │   └── replay_screen.dart             # 棋譜リプレイ画面
│   └── providers/
│       └── repository_providers.dart      # Providerの依存注入定義
│
├── core/
│   └── utils/
│       └── (オプション)
│
├── l10n/
│   └── app_localizations.dart             # 多言語対応(国際化)


  • Presentation層: UIとその独自状態を管理

  • Application層:ユースケース(アプリケーション固有の処理)

  • Domain層: ビジネスルールの中心。エンティティ、インターフェース、ドメインサービスなどを保持

  • Data層: 外部リソースへのアクセス実装(DB、APIなど)

アーキテクチャーの依存分離の意義

  • IGameHistoryRepository をドメインに置くことで、UseCaseやViewModelはData層に依存せず、テスト性が高まります

  • Providerを分離することで、部分的なモックとしての交換も容易

コードのカプセル化

Data層

database_helper
class DatabaseHelper {
  static final DatabaseHelper _instance = DatabaseHelper._internal();

  factory DatabaseHelper() => _instance;

  DatabaseHelper._internal();

  static const String _dbName = 'chess_records.db';

  static const int _dbVersion = 1;

  static Database? _database;

  Future<Database> get database async {
    return _database ??= await _initDatabase();
  }

  Future<Database> _initDatabase() async {
    try {
      final dbPath = await getDatabasesPath();
      final path = join(dbPath, _dbName);

      return await openDatabase(
        path,
        version: _dbVersion,
        onCreate: _onCreate,
        onUpgrade: _onUpgrade,
      );
    } catch (e) {
      throw DatabaseException('Failed to initialize database: $e');
    }
  }

  // Create database tables
  Future<void> _onCreate(Database db, int version) async {
    await db.execute(GameHistoryTable.createTableSql());
    await db.execute(GameHistoryTable.createIndexSql());
  }

  // Handle database upgrades between versions
  Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
    // Will implement version migration logic when needed
    // if (oldVersion < 2 && newVersion >= 2) {
    //   // For future upgrades
    // }
  }

  // Close database connection
  Future<void> close() async {
    if (_database != null) {
      await _database!.close();
      _database = null;
    }
  }
}

// Custom exception class for database errors
class DatabaseException implements Exception {
  final String message;

  DatabaseException(this.message);

  @override
  String toString() => 'DatabaseException: $message';
}

GameHistoryTable
class GameHistoryTable {
  static const String tableName = 'game_history';
  static const String colId = 'id';
  static const colWhiteName = 'white_name'; // White player's name (e.g., “Alice”)
  static const colBlackName = 'black_name'; // Black player's name (e.g., “Bob”)
  static const String colResult =
      'result'; // Game result, such as 1-0, 0-1, 1/2-1/2, or * (asterisk) if unfinished
  static const String colPgn = 'pgn'; // PGN (Portable Game Notation) text, includes headers and moves
  static const String colFen = 'fen'; // Optional: FEN string representing the final board state
  static const colStartTime = 'start_time'; // Game start time in ISO-8601 string format
  static const colEndTime = 'end_time'; // Game end time in ISO-8601 string format
  static const colIsFinished = 'is_finished'; // Game completion flag (1 = finished, 0 = unfinished)

  static String createTableSql() {
    return '''
      CREATE TABLE $tableName (
        $colId INTEGER PRIMARY KEY AUTOINCREMENT,
        $colWhiteName TEXT NOT NULL,
        $colBlackName TEXT NOT NULL,
        $colResult TEXT NOT NULL,
        $colPgn TEXT NOT NULL,
        $colFen TEXT,
        $colStartTime TEXT NOT NULL,
        $colEndTime TEXT,
        $colIsFinished INTEGER NOT NULL DEFAULT 0
      );
    ''';
  }

  static String createIndexSql() {
    return '''
      CREATE INDEX IF NOT EXISTS idx_player ON $tableName ($colWhiteName, $colBlackName);
    ''';
  }
}

BaseRepository
abstract class BaseRepository {
  Future<T> wrapDbCall<T>(Future<T> Function() action) async {
    try {
      return await action();
    } catch (e) {
      throw DatabaseException('DB operation failed: $e}');
    }
  }

  Future<Database> getDb() => DatabaseHelper().database;

  Future<List<dynamic>> wrapBatchCall(
    Future<List<dynamic>> Function(Batch) handler,
  ) async {
    return wrapDbCall(() async {
      final db = await getDb();
      final batch = db.batch();
      final result = await handler(batch);
      return await batch.commit();
    });
  }

  Future<int> count(String table) async {
    return wrapDbCall(() async {
      final db = await getDb();
      return Sqflite.firstIntValue(
            await db.rawQuery('SELECT COUNT(*) FROM $table'),
          ) ??
          0;
    });
  }

  Future<int> deleteAll(String table) async {
    return wrapDbCall(() async {
      final db = await getDb();
      return await db.delete(table);
    });
  }

  Future<List<Map<String, dynamic>>> queryWhere({
    required String table,
    required String where,
    required List<Object?> whereArgs,
    String? orderBy,
    int? limit,
  }) async {
    return wrapDbCall(() async {
      final db = await getDb();
      return await db.query(
        table,
        where: where,
        whereArgs: whereArgs,
        orderBy: orderBy,
        limit: limit,
      );
    });
  }

  // Inserts a single record into the specified table.
  Future<int> insert(
    String table,
    Map<String, dynamic> data, {
    ConflictAlgorithm? conflictAlgorithm,
  }) async {
    return wrapDbCall(() async {
      final db = await getDb();
      return db.insert(table, data, conflictAlgorithm: conflictAlgorithm);
    });
  }

  // Inserts multiple records in a batch operation.
  Future<int> update(
    String table,
    Map<String, dynamic> data, {
    required String where,
    required List<Object?> whereArgs,
  }) async {
    return wrapDbCall(() async {
      final db = await getDb();
      return db.update(table, data, where: where, whereArgs: whereArgs);
    });
  }

  // Deletes records from the specified table based on the provided conditions.
  Future<int> delete(
    String table, {
    required String where,
    required List<Object?> whereArgs,
  }) async {
    return wrapDbCall(() async {
      final db = await getDb();
      return db.delete(table, where: where, whereArgs: whereArgs);
    });
  }

  // Executes a raw SQL query and returns the results as a list of maps.
  Future<List<Map<String, dynamic>>> rawQuery(
    String sql, [
    List<Object?>? args,
  ]) async {
    return wrapDbCall(() async {
      final db = await getDb();
      return db.rawQuery(sql, args);
    });
  }
}

GameHistoryRepository
class GameHistoryRepository extends BaseRepository
    implements IGameHistoryRepository {
  Future<int> insertRecord(GameRecord record) {
    return insert(
      GameHistoryTable.tableName,
      record.toMap(),
      conflictAlgorithm: ConflictAlgorithm.replace,
    );
  }

  Future<List<int>> insertRecords(List<GameRecord> records) {
    return wrapBatchCall((batch) {
      for (final r in records) {
        batch.insert(
          GameHistoryTable.tableName,
          r.toMap(),
          conflictAlgorithm: ConflictAlgorithm.replace,
        );
      }
      return Future.value([]);
    }).then((value) => value.cast<int>());
  }

  Future<List<GameRecord>> getAllRecords({
    int? limit,
    int? offset,
    bool orderDesc = true,
  }) async {
    final maps = await queryWhere(
      table: GameHistoryTable.tableName,
      where: '1 = 1',
      // 全件取得
      whereArgs: [],
      orderBy: buildOrderByClause(orderDesc, GameHistoryTable.colEndTime),
      limit: limit,
    );
    return maps.map(GameRecord.fromMap).toList();
  }

  Future<int> deleteRecordById(int id) {
    return delete(
      GameHistoryTable.tableName,
      where: '${GameHistoryTable.colId} = ?',
      whereArgs: [id],
    );
  }

  Future<int> updateResult(int id, String result, {bool isFinished = true}) {
    return update(
      GameHistoryTable.tableName,
      {
        GameHistoryTable.colResult: result,
        GameHistoryTable.colIsFinished: isFinished ? 1 : 0,
        GameHistoryTable.colEndTime: DateTime.now().toIso8601String(),
      },
      where: '${GameHistoryTable.colId} = ?',
      whereArgs: [id],
    );
  }

  Future<List<GameRecord>> getRecordsByPlayer(String playerName) async {
    final maps = await queryWhere(
      table: GameHistoryTable.tableName,
      where:
          '${GameHistoryTable.colWhiteName} = ? OR ${GameHistoryTable.colBlackName} = ?',
      whereArgs: [playerName, playerName],
      orderBy: '${GameHistoryTable.colEndTime} DESC',
    );
    return maps.map(GameRecord.fromMap).toList();
  }

  Future<GameRecord?> getRecordById(int id) async {
    final maps = await queryWhere(
      table: GameHistoryTable.tableName,
      where: '${GameHistoryTable.colId} = ?',
      whereArgs: [id],
      limit: 1,
    );
    return maps.isNotEmpty ? GameRecord.fromMap(maps.first) : null;
  }

  Future<int> updateRecord(GameRecord record) {
    return update(
      GameHistoryTable.tableName,
      record.toMap(),
      where: '${GameHistoryTable.colId} = ?',
      whereArgs: [record.id],
    );
  }

  Future<List<GameRecord>> getUnfinishedGames() async {
    final maps = await queryWhere(
      table: GameHistoryTable.tableName,
      where: '${GameHistoryTable.colIsFinished} = ?',
      whereArgs: [0],
      orderBy: '${GameHistoryTable.colStartTime} DESC',
    );
    return maps.map(GameRecord.fromMap).toList();
  }

  Future<List<GameRecord>> searchByPlayer(String keyword) async {
    final maps = await queryWhere(
      table: GameHistoryTable.tableName,
      where:
          '${GameHistoryTable.colWhiteName} LIKE ? OR ${GameHistoryTable.colBlackName} LIKE ?',
      whereArgs: ['%$keyword%', '%$keyword%'],
      orderBy: '${GameHistoryTable.colEndTime} DESC',
    );
    return maps.map(GameRecord.fromMap).toList();
  }

  Future<int> updateResultWithPGN({
    required int id,
    required String result,
    required String pgn,
    String? fen,
    bool isFinished = true,
  }) {
    return update(
      GameHistoryTable.tableName,
      {
        GameHistoryTable.colResult: result,
        GameHistoryTable.colPgn: pgn,
        GameHistoryTable.colFen: fen ?? '-',
        GameHistoryTable.colIsFinished: isFinished ? 1 : 0,
        GameHistoryTable.colEndTime: DateTime.now().toIso8601String(),
      },
      where: '${GameHistoryTable.colId} = ?',
      whereArgs: [id],
    );
  }

  String buildOrderByClause(bool desc, String column) =>
      '$column ${desc ? "DESC" : "ASC"}';
}

Domain層

IGameHistoryRepository
abstract class IGameHistoryRepository {
  Future<int> insertRecord(GameRecord record);

  Future<List<int>> insertRecords(List<GameRecord> records);

  Future<List<GameRecord>> getAllRecords({
    int? limit,
    int? offset,
    bool orderDesc,
  });

  Future<int> deleteRecordById(int id);

  Future<int> updateResult(int id, String result, {bool isFinished = true});

  Future<List<GameRecord>> getRecordsByPlayer(String playerName);

  Future<GameRecord?> getRecordById(int id);

  Future<int> updateRecord(GameRecord record);

  Future<List<GameRecord>> getUnfinishedGames();

  Future<List<GameRecord>> searchByPlayer(String keyword);

  Future<int> updateResultWithPGN({
    required int id,
    required String result,
    required String pgn,
    String? fen,
    bool isFinished,
  });
}
GameRecord
class GameRecord {
  final int? id;
  final String startTime;
  final String endTime;
  final String whiteName;
  final String blackName;
  final String result;
  final String? pgn;
  final String? fen;
  final int isFinished; // 1 = true, 0 = false

  GameRecord({
    this.id,
    required this.whiteName,
    required this.blackName,
    required this.result,
    this.pgn,
    this.fen,
    String? startTime,
    String? endTime,
    this.isFinished = 0,
  }) : startTime = startTime ?? DateTime.now().toIso8601String(),
       endTime = endTime ?? DateTime.now().toIso8601String();

  factory GameRecord.fromMap(Map<String, dynamic> map) {
    return GameRecord(
      id: map[GameHistoryTable.colId],
      whiteName: map[GameHistoryTable.colWhiteName],
      blackName: map[GameHistoryTable.colBlackName],
      result: map[GameHistoryTable.colResult],
      pgn: map[GameHistoryTable.colPgn],
      fen: map[GameHistoryTable.colFen],
      startTime: map[GameHistoryTable.colStartTime],
      endTime: map[GameHistoryTable.colEndTime],
      isFinished: map[GameHistoryTable.colIsFinished],
    );
  }

  Map<String, dynamic> toMap() {
    return {
      GameHistoryTable.colId: id,
      GameHistoryTable.colWhiteName: whiteName,
      GameHistoryTable.colBlackName: blackName,
      GameHistoryTable.colResult: result,
      GameHistoryTable.colPgn: pgn,
      GameHistoryTable.colFen: fen,
      GameHistoryTable.colStartTime: startTime,
      GameHistoryTable.colEndTime: endTime,
      GameHistoryTable.colIsFinished: isFinished,
    };
  }

  @override
  String toString() {
    return 'GameRecord{id: $id, startTime: $startTime, endTime: $endTime, whiteName: $whiteName, blackName: $blackName, result: $result, pgn: $pgn, fen: $fen, isFinished: $isFinished}';
  }
}

Application層

GetAllGameRecords
class GetAllGameRecords {
  final IGameHistoryRepository repository;

  GetAllGameRecords(this.repository);

  Future<List<GameRecord>> call({
    int? limit,
    int? offset,
    bool orderDesc = true,
  }) {
    return repository.getAllRecords(
      limit: limit,
      offset: offset,
      orderDesc: orderDesc,
    );
  }
}

Presentation層

GameHistoryViewModel
class GameHistoryViewModel extends StateNotifier<AsyncValue<List<GameRecord>>> {
  final GetAllGameRecords _getAllGameRecords;

  GameHistoryViewModel(this._getAllGameRecords) : super(const AsyncLoading()) {
    fetchRecords();
  }

  Future<void> fetchRecords({bool orderDesc = true}) async {
    state = const AsyncLoading();
    try {
      final records = await _getAllGameRecords(orderDesc: orderDesc);
      state = AsyncValue.data(records);
    } catch (e, st) {
      state = AsyncValue.error(e, st);
    }
  }
}
game_history_providers
final gameHistoryRepositoryProvider = Provider<IGameHistoryRepository>((ref) {
  return GameHistoryRepository(); // ここだけdata層に依存してOK
});

final getAllGameRecordsProvider = Provider<GetAllGameRecords>((ref) {
  final repo = ref.read(gameHistoryRepositoryProvider); // Repository注入
  return GetAllGameRecords(repo);
});

final gameHistoryViewModelProvider =
    StateNotifierProvider<GameHistoryViewModel, AsyncValue<List<GameRecord>>>((
      ref,
    ) {
      final usecase = ref.watch(getAllGameRecordsProvider);
      return GameHistoryViewModel(usecase);
    });

まとめ

Clean Architecture を実践すると、各層の責任が明確になり、拡張性やテスト性に優れます。

資源

以下、依存関係図のmermaidコードです。

flowchart TD
  subgraph Presentation
    A1[GameHistoryScreen]
    A2[GameHistoryViewModel]
    A3[ReplayScreen]
    A4[ReplayViewModel]
  end

  subgraph Application
    B1[GetAllGameRecords UseCase]
    B2[GetGameRecordById UseCase]
  end

  subgraph Domain
    C1[IGameHistoryRepository]
    C2[GameRecord Entity]
  end

  subgraph Data
    D1[GameHistoryRepository]
    D2[BaseRepository]
    D3[DatabaseHelper]
    D4[GameHistoryTable]
  end
  
 %% Presentation -> Application
  A1 --> A2
  A2 --> B1
  A3 --> A4
  A4 --> B2
 %% Application -> Domain (Interface)
  B1 --> C1
  B2 --> C1
  
 %% Data -> Domain (Implements)
  D1 -->C1
  
  %% Domain Entity
 B1 --> C2
 B2 --> C2
 D1 --> C2

  %% Data Internal dependencies
 D1 --> D2
 D1 --> D3
 D1 --> D4
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?