Flutter | バスケットボールスコア入力アプリに保存機能を追加

先日作成したFlutterを使ったバスケットボールのスコア入力アプリ(ランニングスコア)にSQLiteを使ってデータを保存機能を追加しました。

アプリの機能

  • スコア入力: チームとプレイ内容を選択し、得点を入力します。
  • 試合管理: 保存された試合を一覧表示し、選択して詳細を確認できます。
  • データ保存: 入力したデータをSQLiteデータベースに保存します。

ファイル構成

lib/
│
├── main.dart
├── database_helper.dart
├── game_entry_provider.dart
├── play_entry_provider.dart
└── manage_games_screen.dart

アプリの実装

main.dart

このファイルは、アプリケーションのエントリーポイントです。ユーザーがスコアを入力するメイン画面を提供します。

import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'play_entry_provider.dart';
import 'game_entry_provider.dart';
import 'manage_games_screen.dart';

void main() {
  runApp(BasketballScoreApp());
}

class BasketballScoreApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'バスケットボール スコア入力アプリ',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: ScoreHomePage(),
      debugShowCheckedModeBanner: false, // デバッグラベルを非表示にする
    );
  }
}

class ScoreHomePage extends StatefulWidget {
  @override
  _ScoreHomePageState createState() => _ScoreHomePageState();
}

class _ScoreHomePageState extends State<ScoreHomePage> {
  final PlayEntryProvider _playProvider = PlayEntryProvider();
  final GameEntryProvider _gameProvider = GameEntryProvider();
  int? _currentGameId;
  List<PlayEntry> _plays = [];
  String? _selectedTeam;
  int _selectedQuarter = 1;
  String? _selectedPlayer;

  @override
  void initState() {
    super.initState();
    _showGameNameDialog(); // アプリ起動時に試合名を入力
  }

  // 新しい試合を開始する
  void _startNewGame(String gameName) async {
    String currentDate = DateFormat('yyyy-MM-dd HH:mm:ss').format(DateTime.now());
    GameEntry game = GameEntry(name: gameName, date: currentDate);
    int gameId = await _gameProvider.insertGame(game);
    setState(() {
      _currentGameId = gameId;
      _plays = [];
    });
  }

  // 試合名を入力するダイアログを表示
  void _showGameNameDialog() {
    showDialog(
      context: context,
      builder: (BuildContext context) {
        TextEditingController _gameNameController = TextEditingController();

        return AlertDialog(
          title: Text("新しい試合"),
          content: TextField(
            controller: _gameNameController,
            decoration: InputDecoration(hintText: "試合名を入力"),
          ),
          actions: [
            TextButton(
              onPressed: () {
                String gameName = _gameNameController.text;
                if (gameName.isNotEmpty) {
                  Navigator.of(context).pop();
                  _startNewGame(gameName);
                }
              },
              child: Text("開始"),
            ),
          ],
        );
      },
    );
  }

  // プレイ内容を追加する
  void _addPlay(String team, int points, String playType) async {
    String formattedTime = DateFormat('H:mm:ss').format(DateTime.now());
    PlayEntry newPlay = PlayEntry(
      team: team,
      points: points,
      quarter: _selectedQuarter,
      playType: playType,
      time: formattedTime,
      playerNumber: _selectedPlayer ?? 'TEAM',
      gameId: _currentGameId!,
    );
    await _playProvider.insertPlay(newPlay);
    _loadPlays();
    setState(() {
      _selectedPlayer = null; // リセット
      _selectedTeam = null; // リセット
    });
  }

  // データベースからプレイデータをロードする
  void _loadPlays() async {
    if (_currentGameId != null) {
      List<PlayEntry> plays = await _playProvider.getPlaysByGameId(_currentGameId!);
      setState(() {
        _plays = plays.reversed.toList(); // 降順で表示
      });
    }
  }

  // 合計得点を取得する
  int _getTotalScore(String team) {
    return _plays
        .where((entry) => entry.team == team)
        .fold(0, (sum, item) => sum + item.points);
  }

  // クォーター切り替えダイアログを表示
  void _showQuarterDialog(int quarter) {
    showDialog(
      context: context,
      builder: (BuildContext context) {
        return AlertDialog(
          title: Text("クォーター切り替え"),
          content: Text("本当にクォーターを $quarter に切り替えますか?"),
          actions: [
            TextButton(
              onPressed: () {
                setState(() {
                  _selectedQuarter = quarter;
                });
                Navigator.of(context).pop();
              },
              child: Text("はい"),
            ),
            TextButton(
              onPressed: () {
                Navigator.of(context).pop();
              },
              child: Text("いいえ"),
            ),
          ],
        );
      },
    );
  }

  // プレイ削除の確認ダイアログを表示
  void _showDeleteConfirmationDialog(PlayEntry entry) {
    showDialog(
      context: context,
      builder: (BuildContext context) {
        return AlertDialog(
          title: Text("プレイ削除"),
          content: Text("本当にこのプレイ内容を削除しますか?"),
          actions: [
            TextButton(
              onPressed: () async {
                await _playProvider.deletePlay(entry.id!);
                _loadPlays(); // リストを更新
                Navigator.of(context).pop();
              },
              child: Text("はい"),
            ),
            TextButton(
              onPressed: () {
                Navigator.of(context).pop();
              },
              child: Text("いいえ"),
            ),
          ],
        );
      },
    );
  }

  // 選手を設定する
  void _setPlayer(String player) {
    setState(() {
      _selectedPlayer = player;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('バスケットボール スコア入力アプリ'),
        actions: [
          IconButton(
            icon: Icon(Icons.list),
            onPressed: () {
              Navigator.push(
                context,
                MaterialPageRoute(
                  builder: (context) => ManageGamesScreen(
                    onGameSelected: (game) {
                      Navigator.pop(context);
                      setState(() {
                        _currentGameId = game.id;
                      });
                      _loadPlays();
                    },
                  ),
                ),
              );
            },
          ),
        ],
      ),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          Padding(
            padding: const EdgeInsets.symmetric(vertical: 8.0),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text(
                  'チームA',
                  style: TextStyle(fontSize: 24),
                ),
                SizedBox(width: 10),
                Text(
                  '${_getTotalScore('A')}',
                  style: TextStyle(fontSize: 24),
                ),
                SizedBox(width: 10),
                Text(
                  '-',
                  style: TextStyle(fontSize: 24),
                ),
                SizedBox(width: 10),
                Text(
                  '${_getTotalScore('B')}',
                  style: TextStyle(fontSize: 24),
                ),
                SizedBox(width: 10),
                Text(
                  'チームB',
                  style: TextStyle(fontSize: 24),
                ),
              ],
            ),
          ),
          SizedBox(height: 20),
          Text(
            'クォーターを選択してください:',
            style: TextStyle(fontSize: 20),
          ),
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              ElevatedButton(
                onPressed: () => _showQuarterDialog(1),
                child: Text('1Q'),
                style: ElevatedButton.styleFrom(
                  backgroundColor:
                      _selectedQuarter == 1 ? Colors.blue : Colors.grey,
                ),
              ),
              SizedBox(width: 10),
              ElevatedButton(
                onPressed: () => _showQuarterDialog(2),
                child: Text('2Q'),
                style: ElevatedButton.styleFrom(
                  backgroundColor:
                      _selected

Quarter == 2 ? Colors.blue : Colors.grey,
                ),
              ),
              SizedBox(width: 10),
              ElevatedButton(
                onPressed: () => _showQuarterDialog(3),
                child: Text('3Q'),
                style: ElevatedButton.styleFrom(
                  backgroundColor:
                      _selectedQuarter == 3 ? Colors.blue : Colors.grey,
                ),
              ),
              SizedBox(width: 10),
              ElevatedButton(
                onPressed: () => _showQuarterDialog(4),
                child: Text('4Q'),
                style: ElevatedButton.styleFrom(
                  backgroundColor:
                      _selectedQuarter == 4 ? Colors.blue : Colors.grey,
                ),
              ),
            ],
          ),
          SizedBox(height: 20),
          Text(
            'チームを選択してください:',
            style: TextStyle(fontSize: 20),
          ),
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              ElevatedButton(
                onPressed: () => setState(() => _selectedTeam = 'A'),
                child: Text('チームA'),
                style: ElevatedButton.styleFrom(
                  backgroundColor:
                      _selectedTeam == 'A' ? Colors.blue : Colors.grey,
                ),
              ),
              SizedBox(width: 20),
              ElevatedButton(
                onPressed: () => setState(() => _selectedTeam = 'B'),
                child: Text('チームB'),
                style: ElevatedButton.styleFrom(
                  backgroundColor:
                      _selectedTeam == 'B' ? Colors.blue : Colors.grey,
                ),
              ),
            ],
          ),
          SizedBox(height: 20),
          Text(
            '選手の番号を選択してください:',
            style: TextStyle(fontSize: 20),
          ),
          SingleChildScrollView(
            scrollDirection: Axis.horizontal,
            child: Row(
              children: List<Widget>.generate(
                12,
                (int index) {
                  int playerNumber = index + 4;
                  return ElevatedButton(
                    onPressed: () => _setPlayer(playerNumber.toString()),
                    child: Text(playerNumber.toString()),
                    style: ElevatedButton.styleFrom(
                      backgroundColor:
                          _selectedPlayer == playerNumber.toString()
                              ? Colors.blue
                              : Colors.grey,
                    ),
                  );
                },
              )..add(
                  ElevatedButton(
                    onPressed: () => _setPlayer('TEAM'),
                    child: Text('TEAM'),
                    style: ElevatedButton.styleFrom(
                      backgroundColor:
                          _selectedPlayer == 'TEAM' ? Colors.blue : Colors.grey,
                    ),
                  ),
                ),
            ),
          ),
          SizedBox(height: 20),
          Text(
            '内容を選択してください:',
            style: TextStyle(fontSize: 20),
          ),
          Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              ElevatedButton(
                onPressed: _selectedTeam == null || _selectedPlayer == null
                    ? null
                    : () => _addPlay(_selectedTeam!, 1, 'FT'),
                child: Text('FT'),
              ),
              SizedBox(width: 10),
              ElevatedButton(
                onPressed: _selectedTeam == null || _selectedPlayer == null
                    ? null
                    : () => _addPlay(_selectedTeam!, 2, '2P'),
                child: Text('2P'),
              ),
              SizedBox(width: 10),
              ElevatedButton(
                onPressed: _selectedTeam == null || _selectedPlayer == null
                    ? null
                    : () => _addPlay(_selectedTeam!, 3, '3P'),
                child: Text('3P'),
              ),
              SizedBox(width: 10),
              ElevatedButton(
                onPressed: _selectedTeam == null || _selectedPlayer == null
                    ? null
                    : () => _addPlay(_selectedTeam!, 0, 'ファウル'),
                child: Text('ファウル'),
              ),
              SizedBox(width: 10),
              ElevatedButton(
                onPressed: _selectedPlayer != 'TEAM'
                    ? null
                    : () => _addPlay(_selectedTeam!, 0, 'タイムアウト'),
                child: Text('タイムアウト'),
              ),
            ],
          ),
          SizedBox(height: 20),
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16.0),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text('クォーター', style: TextStyle(fontWeight: FontWeight.bold)),
                Text('チーム', style: TextStyle(fontWeight: FontWeight.bold)),
                Text('選手', style: TextStyle(fontWeight: FontWeight.bold)),
                Text('内容', style: TextStyle(fontWeight: FontWeight.bold)),
                Text('時間', style: TextStyle(fontWeight: FontWeight.bold)),
                Text('操作', style: TextStyle(fontWeight: FontWeight.bold)),
              ],
            ),
          ),
          Expanded(
            child: ReorderableListView(
              onReorder: (int oldIndex, int newIndex) {
                setState(() {
                  if (newIndex > oldIndex) {
                    newIndex -= 1;
                  }
                  final item = _plays.removeAt(oldIndex);
                  _plays.insert(newIndex, item);
                });
              },
              children: _plays
                  .where((entry) => entry.quarter == _selectedQuarter)
                  .map((entry) => Container(
                        key: ValueKey(entry.id),
                        decoration: BoxDecoration(
                          border: Border(
                            bottom: BorderSide(color: Colors.grey),
                          ),
                        ),
                        child: ListTile(
                          title: Row(
                            mainAxisAlignment: MainAxisAlignment.spaceBetween,
                            children: [
                              Text('${entry.quarter}Q'),
                              Text('チーム${entry.team}'),
                              Text('${entry.playerNumber}'),
                              Text('${entry.playType}'),
                              Text('${entry.time}'),
                            ],
                          ),
                          trailing: IconButton(
                            icon: Icon(Icons.delete),
                            onPressed: () =>
                                _showDeleteConfirmationDialog(entry),
                          ),
                          contentPadding:
                              EdgeInsets.symmetric(vertical: 0, horizontal: 16),
                          dense: true,
                        ),
                      ))
                  .toList(),
            ),
          ),
        ],
      ),
    );
  }
}

database_helper.dart

このファイルは、データベースの初期化と操作をサポートします。

import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';

class DatabaseHelper {
  static final DatabaseHelper instance = DatabaseHelper._privateConstructor();
  static Database? _database;

  DatabaseHelper._privateConstructor();

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

  Future<Database> _initDatabase() async {
    String path = join(await getDatabasesPath(), 'basketball_score.db');
    return await openDatabase(
      path,
      version: 1,
      onCreate: _onCreate,
    );
  }

  Future _onCreate(Database db, int version) async {
    await db.execute('''
      CREATE TABLE games (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        name TEXT NOT NULL,
        date TEXT NOT NULL
      )
    ''');

    await db.execute('''
      CREATE TABLE plays (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        team TEXT NOT NULL,
        points INTEGER NOT NULL,
        quarter INTEGER NOT NULL,
        playType TEXT NOT NULL,
        time TEXT NOT NULL,
        playerNumber TEXT NOT NULL,
        gameId INTEGER NOT NULL,
        FOREIGN KEY (gameId) REFERENCES games (id) ON DELETE CASCADE
      )
    ''');
  }
}

game_entry_provider.dart

このファイルは、試合データの取得、保存、削除を行います。

import 'package:sqflite/sqflite.dart';
import 'database_helper.dart';

class GameEntry {
  final int? id;
  final String name;
  final String date;

  GameEntry({this.id, required this.name, required this.date});

  Map<String, dynamic> toMap() {
    return {
      'id': id,
      'name': name,
      'date': date,
    };
  }

  factory GameEntry.fromMap(Map<String, dynamic> map) {
    return GameEntry(
      id: map['id'],
      name: map['name'],
      date: map['date'],
    );
  }
}

class GameEntryProvider {
  final DatabaseHelper _dbHelper = DatabaseHelper.instance;

  Future<int> insertGame(GameEntry game) async {
    final db = await _dbHelper.database;
    return await db.insert('games', game.toMap(),
        conflictAlgorithm: ConflictAlgorithm.replace);
  }

  Future<List<GameEntry>> getAllGames() async {
    final db = await _dbHelper.database;
    final List<Map<String, dynamic>> maps = await db.query('games');
    return List.generate(maps.length, (i) {
      return GameEntry.fromMap(maps[i]);
    });
  }

  Future<void> deleteGame(int id) async {
    final db = await _dbHelper.database;
    await db.delete(
      'games',
      where: 'id = ?',
      whereArgs: [id],
    );
  }
}

play_entry_provider.dart

このファイルは、プレイ内容のデータを管理します。

import 'package:sqflite/sqflite.dart';
import 'database_helper.dart';

class PlayEntry {
  final int? id;
  final String team;
  final int points;
  final int quarter;
  final

 String playType;
  final String time;
  final String playerNumber;
  final int gameId;

  PlayEntry({
    this.id,
    required this.team,
    required this.points,
    required this.quarter,
    required this.playType,
    required this.time,
    required this.playerNumber,
    required this.gameId,
  });

  Map<String, dynamic> toMap() {
    return {
      'id': id,
      'team': team,
      'points': points,
      'quarter': quarter,
      'playType': playType,
      'time': time,
      'playerNumber': playerNumber,
      'gameId': gameId,
    };
  }

  factory PlayEntry.fromMap(Map<String, dynamic> map) {
    return PlayEntry(
      id: map['id'],
      team: map['team'],
      points: map['points'],
      quarter: map['quarter'],
      playType: map['playType'],
      time: map['time'],
      playerNumber: map['playerNumber'],
      gameId: map['gameId'],
    );
  }
}

class PlayEntryProvider {
  final DatabaseHelper _dbHelper = DatabaseHelper.instance;

  Future<int> insertPlay(PlayEntry play) async {
    final db = await _dbHelper.database;
    return await db.insert('plays', play.toMap(),
        conflictAlgorithm: ConflictAlgorithm.replace);
  }

  Future<List<PlayEntry>> getPlaysByGameId(int gameId) async {
    final db = await _dbHelper.database;
    final List<Map<String, dynamic>> maps = await db.query('plays',
        where: 'gameId = ?', whereArgs: [gameId]);
    return List.generate(maps.length, (i) {
      return PlayEntry.fromMap(maps[i]);
    });
  }

  Future<void> deletePlay(int id) async {
    final db = await _dbHelper.database;
    await db.delete(
      'plays',
      where: 'id = ?',
      whereArgs: [id],
    );
  }
}

manage_games_screen.dart

このファイルは、試合データを管理する画面を提供します。

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

class ManageGamesScreen extends StatefulWidget {
  final ValueChanged<GameEntry> onGameSelected;

  ManageGamesScreen({required this.onGameSelected});

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

class _ManageGamesScreenState extends State<ManageGamesScreen> {
  List<GameEntry> _games = [];
  final GameEntryProvider _gameProvider = GameEntryProvider();

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

  Future<void> _loadGames() async {
    List<GameEntry> games = await _gameProvider.getAllGames();
    setState(() {
      _games = games;
    });
  }

  void _showDeleteConfirmationDialog(GameEntry game) {
    showDialog(
      context: context,
      builder: (BuildContext context) {
        return AlertDialog(
          title: Text("試合削除"),
          content: Text("本当にこの試合を削除しますか?"),
          actions: [
            TextButton(
              onPressed: () async {
                await _gameProvider.deleteGame(game.id!);
                await _loadGames();
                Navigator.of(context).pop();
              },
              child: Text("はい"),
            ),
            TextButton(
              onPressed: () {
                Navigator.of(context).pop();
              },
              child: Text("いいえ"),
            ),
          ],
        );
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('試合管理'),
      ),
      body: _games.isEmpty
          ? Center(child: Text('保存された試合はありません'))
          : ListView.builder(
              itemCount: _games.length,
              itemBuilder: (context, index) {
                final game = _games[index];
                return ListTile(
                  title: Text(game.name),
                  subtitle: Text(game.date),
                  trailing: IconButton(
                    icon: Icon(Icons.delete),
                    onPressed: () => _showDeleteConfirmationDialog(game),
                  ),
                  onTap: () {
                    widget.onGameSelected(game);
                  },
                );
              },
            ),
    );
  }
}

画面イメージ

アプリ起動後の画面

入力画面

保存データ管理画面

終わりに

SQLiteを用いたデータ保存機能を追加しました。今後も機能拡張、改善など続けてまいります。

関連記事

コメント

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