学内・地域で使える!Flutterで簡単イベント管理アプリ【要件定義付き】 のサムネイル

学内・地域で使える!Flutterで簡単イベント管理アプリ【要件定義付き】

更新:

The Prince Academy株式会社・Seino AI・DX Lab

なぜ必要?

学校・地域・サークルでは、

  • 「いつどこでイベントがあるのか分からない」
  • 「LINEに流れて消えた」
  • 「SNSはやっていない人もいる」 という問題がよく起きます。この問題をアプリで解決してみましょう。

課題設定(問題をはっきりさせる)

  1. 情報が散らばって見つけにくい
  2. 直近の予定が一目で分からない
  3. 参加人数が把握しづらい
  4. ネットが不安定だと見られない/書けない

要件定義(解決のためのルール)

機能要件

  • F1: イベントを作成できる(タイトル/説明/日付・時間/場所)
  • F2: イベントにコメントできる
  • F3: 参加表明(参加/取り消し)ができる
  • F4: カレンダーで日付ごとにイベント一覧が見える
  • F5: オフライン保存(端末ローカルDB)で動く

非機能要件

  • N1: 操作はシンプル(中学生でも迷わない)
  • N2: 起動が速い(ローカルDB)
  • N3: プライバシー配慮(個人情報は名前のみ。外部送信なし)
  • N4: iOS/Androidどちらでも動作(Flutter)

設計(どう作る?)

技術選定

  • Flutter:1つのコードで iOS/Android 両対応
  • Hive:端末内に軽量データ保存(オフラインOK)
  • table_calendar:カレンダー表示

画面設計

  1. 表示名設定…はじめにニックネームを保存
  2. ホーム…カレンダー+その日のイベント一覧
  3. イベント作成…タイトル/説明/日時/場所を入力
  4. イベント詳細…内容+参加表明+コメント一覧/投稿

データ設計(シンプルな3テーブル)

  • Event … イベント本体(id, title, description, startAt, endAt, location, createdAt)
  • Comment … コメント(id, eventId, authorName, message, createdAt)
  • Rsvp … 参加表明(id, eventId, userName, createdAt, going)

関係性

Event 1 ── * Comment

Event 1 ── * Rsvp

実装

pubspec.yaml

name: nichibi202502001
description: "A new Flutter project."
publish_to: 'none'

version: 1.0.0+1

environment:
  sdk: ^3.8.1

dependencies:
  flutter:
    sdk: flutter
  table_calendar: ^3.0.9
  hive: ^2.2.3
  hive_flutter: ^1.1.0
  uuid: ^4.5.1
  shared_preferences: ^2.3.2
  cupertino_icons: ^1.0.8

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true

lib/models.dart

import 'package:hive/hive.dart';

/// 役割: イベント情報
@HiveType(typeId: 1)
class Event extends HiveObject {
  @HiveField(0) final String id;
  @HiveField(1) final String title;
  @HiveField(2) final String description;
  @HiveField(3) final DateTime startAt;
  @HiveField(4) final DateTime? endAt;
  @HiveField(5) final String? location;
  @HiveField(6) final DateTime createdAt;

  Event({
    required this.id,
    required this.title,
    required this.description,
    required this.startAt,
    this.endAt,
    this.location,
    required this.createdAt,
  });
}

/// 役割: コメント
@HiveType(typeId: 2)
class Comment extends HiveObject {
  @HiveField(0) final String id;
  @HiveField(1) final String eventId;
  @HiveField(2) final String authorName;
  @HiveField(3) final String message;
  @HiveField(4) final DateTime createdAt;

  Comment({
    required this.id,
    required this.eventId,
    required this.authorName,
    required this.message,
    required this.createdAt,
  });
}

/// 役割: 参加表明(RSVP)
@HiveType(typeId: 3)
class Rsvp extends HiveObject {
  @HiveField(0) final String id;
  @HiveField(1) final String eventId;
  @HiveField(2) final String userName;
  @HiveField(3) final DateTime createdAt;
  @HiveField(4) final bool going;

  Rsvp({
    required this.id,
    required this.eventId,
    required this.userName,
    required this.createdAt,
    required this.going,
  });
}

/* ===== 手書き TypeAdapter(build_runner不要) ===== */

class EventAdapter extends TypeAdapter<Event> {
  @override
  final int typeId = 1;

  @override
  Event read(BinaryReader reader) {
    return Event(
      id: reader.readString(),
      title: reader.readString(),
      description: reader.readString(),
      startAt: DateTime.fromMillisecondsSinceEpoch(reader.readInt()),
      endAt: reader.readBool()
          ? DateTime.fromMillisecondsSinceEpoch(reader.readInt())
          : null,
      location: reader.readBool() ? reader.readString() : null,
      createdAt: DateTime.fromMillisecondsSinceEpoch(reader.readInt()),
    );
  }

  @override
  void write(BinaryWriter writer, Event obj) {
    writer.writeString(obj.id);
    writer.writeString(obj.title);
    writer.writeString(obj.description);
    writer.writeInt(obj.startAt.millisecondsSinceEpoch);
    if (obj.endAt != null) {
      writer.writeBool(true);
      writer.writeInt(obj.endAt!.millisecondsSinceEpoch);
    } else {
      writer.writeBool(false);
    }
    if (obj.location != null) {
      writer.writeBool(true);
      writer.writeString(obj.location!);
    } else {
      writer.writeBool(false);
    }
    writer.writeInt(obj.createdAt.millisecondsSinceEpoch);
  }
}

class CommentAdapter extends TypeAdapter<Comment> {
  @override
  final int typeId = 2;

  @override
  Comment read(BinaryReader reader) {
    return Comment(
      id: reader.readString(),
      eventId: reader.readString(),
      authorName: reader.readString(),
      message: reader.readString(),
      createdAt: DateTime.fromMillisecondsSinceEpoch(reader.readInt()),
    );
  }

  @override
  void write(BinaryWriter writer, Comment obj) {
    writer.writeString(obj.id);
    writer.writeString(obj.eventId);
    writer.writeString(obj.authorName);
    writer.writeString(obj.message);
    writer.writeInt(obj.createdAt.millisecondsSinceEpoch);
  }
}

class RsvpAdapter extends TypeAdapter<Rsvp> {
  @override
  final int typeId = 3;

  @override
  Rsvp read(BinaryReader reader) {
    return Rsvp(
      id: reader.readString(),
      eventId: reader.readString(),
      userName: reader.readString(),
      createdAt: DateTime.fromMillisecondsSinceEpoch(reader.readInt()),
      going: reader.readBool(),
    );
  }

  @override
  void write(BinaryWriter writer, Rsvp obj) {
    writer.writeString(obj.id);
    writer.writeString(obj.eventId);
    writer.writeString(obj.userName);
    writer.writeInt(obj.createdAt.millisecondsSinceEpoch);
    writer.writeBool(obj.going);
  }
}

lib/local_db.dart

import 'package:hive_flutter/hive_flutter.dart';
import 'package:uuid/uuid.dart';
import 'models.dart';

/// 役割: Hive初期化とCRUDユースケース
class LocalDb {
  static const eventsBoxName = 'events';
  static const commentsBoxName = 'comments';
  static const rsvpsBoxName = 'rsvps';
  static const settingsBoxName = 'settings';

  final Uuid _uuid = const Uuid();

  late Box<Event> events;
  late Box<Comment> comments;
  late Box<Rsvp> rsvps;
  late Box settings;

  static Future<LocalDb> init() async {
    await Hive.initFlutter();
    Hive.registerAdapter(EventAdapter());
    Hive.registerAdapter(CommentAdapter());
    Hive.registerAdapter(RsvpAdapter());

    final db = LocalDb();
    db.events = await Hive.openBox<Event>(eventsBoxName);
    db.comments = await Hive.openBox<Comment>(commentsBoxName);
    db.rsvps = await Hive.openBox<Rsvp>(rsvpsBoxName);
    db.settings = await Hive.openBox(settingsBoxName);
    return db;
  }

  Future<void> setDisplayName(String name) async {
    await settings.put('displayName', name);
  }

  String? get displayName => settings.get('displayName') as String?;

  Future<Event> createEvent({
    required String title,
    required String description,
    required DateTime startAt,
    DateTime? endAt,
    String? location,
  }) async {
    final ev = Event(
      id: _uuid.v4(),
      title: title,
      description: description,
      startAt: startAt,
      endAt: endAt,
      location: location,
      createdAt: DateTime.now(),
    );
    await events.put(ev.id, ev);
    return ev;
  }

  /// 同一日のイベントを時刻順に返す
  List<Event> listEventsByDay(DateTime day) {
    final start = DateTime(day.year, day.month, day.day);
    final end = start.add(const Duration(days: 1));
    final all = events.values.toList();
    all.sort((a, b) => a.startAt.compareTo(b.startAt));
    return all.where((e) =>
      e.startAt.isAfter(start.subtract(const Duration(milliseconds: 1))) &&
      e.startAt.isBefore(end)
    ).toList();
  }

  Future<Comment> addComment({
    required String eventId,
    required String authorName,
    required String message,
  }) async {
    final c = Comment(
      id: _uuid.v4(),
      eventId: eventId,
      authorName: authorName,
      message: message,
      createdAt: DateTime.now(),
    );
    await comments.put(c.id, c);
    return c;
  }

  List<Comment> listComments(String eventId) {
    final list = comments.values.where((c) => c.eventId == eventId).toList();
    list.sort((a, b) => b.createdAt.compareTo(a.createdAt));
    return list;
  }

  Future<void> toggleRsvp({
    required String eventId,
    required String userName,
  }) async {
    final existing = rsvps.values.firstWhere(
      (r) => r.eventId == eventId && r.userName == userName,
      orElse: () => Rsvp(
        id: '', eventId: '', userName: '',
        createdAt: DateTime.now(), going: false),
    );
    if (existing.id.isEmpty) {
      final r = Rsvp(
        id: _uuid.v4(),
        eventId: eventId,
        userName: userName,
        createdAt: DateTime.now(),
        going: true,
      );
      await rsvps.put(r.id, r);
    } else {
      final updated = Rsvp(
        id: existing.id,
        eventId: existing.eventId,
        userName: existing.userName,
        createdAt: existing.createdAt,
        going: !existing.going,
      );
      await rsvps.put(updated.id, updated);
    }
  }

  List<Rsvp> listGoings(String eventId) {
    return rsvps.values.where((r) => r.eventId == eventId && r.going).toList();
  }

  bool isGoing(String eventId, String userName) {
    final r = rsvps.values.firstWhere(
      (x) => x.eventId == eventId && x.userName == userName,
      orElse: () => Rsvp(
        id: '', eventId: '', userName: '',
        createdAt: DateTime.now(), going: false),
    );
    return r.going;
  }
}

lib/main.dart

import 'package:flutter/material.dart';
import 'local_db.dart';
import 'pages/home_page.dart';
import 'pages/name_gate_page.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final db = await LocalDb.init();
  runApp(App(db: db));
}

class App extends StatelessWidget {
  final LocalDb db;
  const App({super.key, required this.db});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'ローカルイベント掲示板',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF2563EB)),
        useMaterial3: true,
      ),
      routes: {
        '/': (_) => db.displayName == null
            ? NameGatePage(db: db)
            : HomePage(db: db),
        '/home': (_) => HomePage(db: db),
      },
    );
  }
}

lib/pages/name_gate_page.dart

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

/// 初回起動時の表示名設定
class NameGatePage extends StatefulWidget {
  final LocalDb db;
  const NameGatePage({super.key, required this.db});
  @override
  State<NameGatePage> createState() => _NameGatePageState();
}

class _NameGatePageState extends State<NameGatePage> {
  final _ctrl = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('表示名を設定')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            const Text('参加表明・コメントに表示される名前を入力してください。'),
            const SizedBox(height: 12),
            TextField(
              controller: _ctrl,
              decoration: const InputDecoration(
                labelText: '表示名',
                border: OutlineInputBorder(),
              ),
            ),
            const SizedBox(height: 12),
            FilledButton(
              onPressed: () async {
                final name = _ctrl.text.trim();
                if (name.isEmpty) return;
                await widget.db.setDisplayName(name);
                if (!mounted) return;
                Navigator.of(context).pushReplacementNamed('/home');
              },
              child: const Text('はじめる'),
            ),
          ],
        ),
      ),
    );
  }
}

lib/pages/new_event_page.dart

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

/// 新規イベント作成
class NewEventPage extends StatefulWidget {
  final LocalDb db;
  final DateTime initialDate;
  const NewEventPage({super.key, required this.db, required this.initialDate});

  @override
  State<NewEventPage> createState() => _NewEventPageState();
}

class _NewEventPageState extends State<NewEventPage> {
  final _title = TextEditingController();
  final _desc = TextEditingController();
  final _loc = TextEditingController();
  late DateTime _date;
  TimeOfDay _start = const TimeOfDay(hour: 19, minute: 0);
  TimeOfDay? _end;

  @override
  void initState() {
    super.initState();
    _date = DateTime(widget.initialDate.year, widget.initialDate.month, widget.initialDate.day);
  }

  Future<void> _pickDate() async {
    final picked = await showDatePicker(
      context: context,
      firstDate: DateTime(2020),
      lastDate: DateTime(2100),
      initialDate: _date,
    );
    if (picked != null) setState(() => _date = picked);
  }

  Future<void> _pickStart() async {
    final t = await showTimePicker(context: context, initialTime: _start);
    if (t != null) setState(() => _start = t);
  }

  Future<void> _pickEnd() async {
    final base = _end ?? _start.replacing(hour: (_start.hour + 1) % 24);
    final t = await showTimePicker(context: context, initialTime: base);
    if (t != null) setState(() => _end = t);
  }

  @override
  Widget build(BuildContext context) {
    final dateLabel = '${_date.year}/${_date.month}/${_date.day}';
    final startLabel = _start.format(context);
    final endLabel = _end?.format(context) ?? '未設定';

    return Scaffold(
      appBar: AppBar(title: const Text('イベント作成')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          TextField(
            controller: _title,
            decoration: const InputDecoration(labelText: 'タイトル', border: OutlineInputBorder()),
          ),
          const SizedBox(height: 12),
          TextField(
            controller: _desc,
            decoration: const InputDecoration(labelText: '説明', border: OutlineInputBorder()),
            maxLines: 3,
          ),
          const SizedBox(height: 12),
          TextField(
            controller: _loc,
            decoration: const InputDecoration(labelText: '場所(任意)', border: OutlineInputBorder()),
          ),
          const SizedBox(height: 12),
          Row(
            children: [
              Expanded(child: OutlinedButton(onPressed: _pickDate, child: Text('日付: $dateLabel'))),
              const SizedBox(width: 8),
              Expanded(child: OutlinedButton(onPressed: _pickStart, child: Text('開始: $startLabel'))),
              const SizedBox(width: 8),
              Expanded(child: OutlinedButton(onPressed: _pickEnd, child: Text('終了: $endLabel'))),
            ],
          ),
          const SizedBox(height: 16),
          FilledButton.icon(
            onPressed: () async {
              final title = _title.text.trim();
              if (title.isEmpty) return;
              final desc = _desc.text.trim();
              final loc = _loc.text.trim().isEmpty ? null : _loc.text.trim();

              final startAt = DateTime(_date.year, _date.month, _date.day, _start.hour, _start.minute);
              final endAt = _end == null ? null : DateTime(_date.year, _date.month, _date.day, _end!.hour, _end!.minute);

              await widget.db.createEvent(
                title: title,
                description: desc,
                startAt: startAt,
                endAt: endAt,
                location: loc,
              );
              if (!mounted) return;
              Navigator.of(context).pop();
            },
            icon: const Icon(Icons.check),
            label: const Text('作成する'),
          ),
        ],
      ),
    );
  }
}

lib/pages/home_page.dart

import 'package:flutter/material.dart';
import 'package:table_calendar/table_calendar.dart';
import '../local_db.dart';
import '../models.dart';
import 'event_detail_page.dart';
import 'new_event_page.dart';

/// カレンダー+当日イベント一覧
class HomePage extends StatefulWidget {
  final LocalDb db;
  const HomePage({super.key, required this.db});
  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  DateTime _focusedDay = DateTime.now();
  DateTime _selectedDay = DateTime(DateTime.now().year, DateTime.now().month, DateTime.now().day);

  @override
  Widget build(BuildContext context) {
    final events = widget.db.listEventsByDay(_selectedDay);

    return Scaffold(
      appBar: AppBar(
        title: const Text('ローカルイベント掲示板'),
        actions: [
          IconButton(
            tooltip: '表示名を変更',
            icon: const Icon(Icons.person),
            onPressed: () async {
              Navigator.of(context)
                  .push(MaterialPageRoute(
                    builder: (_) => _ChangeNameDialog(db: widget.db),
                    fullscreenDialog: true,
                  ))
                  .then((_) => setState(() {}));
            },
          ),
        ],
      ),
      floatingActionButton: FloatingActionButton.extended(
        onPressed: () async {
          await Navigator.of(context).push(
            MaterialPageRoute(builder: (_) => NewEventPage(db: widget.db, initialDate: _selectedDay)),
          );
          setState(() {});
        },
        icon: const Icon(Icons.add),
        label: const Text('イベントを作成'),
      ),
      body: ListView(
        children: [
          TableCalendar<Event>(
            firstDay: DateTime.utc(2020, 1, 1),
            lastDay: DateTime.utc(2100, 12, 31),
            focusedDay: _focusedDay,
            selectedDayPredicate: (day) => isSameDay(day, _selectedDay),
            onDaySelected: (selectedDay, focusedDay) {
              setState(() {
                _selectedDay = DateTime(selectedDay.year, selectedDay.month, selectedDay.day);
                _focusedDay = focusedDay;
              });
            },
            eventLoader: (day) => widget.db.listEventsByDay(day),
            headerStyle: const HeaderStyle(
              formatButtonVisible: false,
              titleCentered: true,
              leftChevronIcon: Icon(Icons.chevron_left),   // 明示して ? を回避
              rightChevronIcon: Icon(Icons.chevron_right), // 明示して ? を回避
            ),
            calendarStyle: const CalendarStyle(
              todayDecoration: BoxDecoration(color: Color(0xFFDBEAFE), shape: BoxShape.circle),
              selectedDecoration: BoxDecoration(color: Color(0xFF2563EB), shape: BoxShape.circle),
              markerDecoration: BoxDecoration(color: Color(0xFF2563EB), shape: BoxShape.circle),
            ),
          ),
          const Divider(),
          Padding(
            padding: const EdgeInsets.all(16),
            child: Text(
              '${_selectedDay.year}/${_selectedDay.month}/${_selectedDay.day} のイベント',
              style: Theme.of(context).textTheme.titleMedium,
            ),
          ),
          if (events.isEmpty)
            const Padding(
              padding: EdgeInsets.symmetric(horizontal: 16),
              child: Text('この日に登録されたイベントはありません。'),
            ),
          ...events.map((e) => _EventTile(
                event: e,
                onTap: () async {
                  await Navigator.of(context).push(MaterialPageRoute(
                    builder: (_) => EventDetailPage(db: widget.db, event: e),
                  ));
                  setState(() {});
                },
                attendees: widget.db.listGoings(e.id),
              )),
          const SizedBox(height: 24),
        ],
      ),
    );
  }
}

class _EventTile extends StatelessWidget {
  final Event event;
  final VoidCallback onTap;
  final List<Rsvp> attendees;
  const _EventTile({required this.event, required this.onTap, required this.attendees});

  @override
  Widget build(BuildContext context) {
    final start = TimeOfDay.fromDateTime(event.startAt);
    final end = event.endAt != null ? TimeOfDay.fromDateTime(event.endAt!) : null;
    final timeText = end != null ? '${start.format(context)} - ${end.format(context)}' : start.format(context);

    return Card(
      margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
      child: ListTile(
        title: Text(event.title, style: const TextStyle(fontWeight: FontWeight.w600)),
        subtitle: Text('$timeText  ・  ${event.location ?? "場所未設定"}\n${event.description}'),
        isThreeLine: true,
        onTap: onTap,
        trailing: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Icon(Icons.people_alt, size: 18),
            Text('${attendees.length}', style: const TextStyle(fontSize: 12)),
          ],
        ),
      ),
    );
  }
}

class _ChangeNameDialog extends StatefulWidget {
  final LocalDb db;
  const _ChangeNameDialog({required this.db});
  @override
  State<_ChangeNameDialog> createState() => _ChangeNameDialogState();
}

class _ChangeNameDialogState extends State<_ChangeNameDialog> {
  late final TextEditingController _ctrl;
  @override
  void initState() {
    super.initState();
    _ctrl = TextEditingController(text: widget.db.displayName ?? '');
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('表示名の変更')),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          children: [
            TextField(
              controller: _ctrl,
              decoration: const InputDecoration(labelText: '表示名', border: OutlineInputBorder()),
            ),
            const SizedBox(height: 12),
            FilledButton(
              onPressed: () async {
                final name = _ctrl.text.trim();
                if (name.isEmpty) return;
                await widget.db.setDisplayName(name);
                if (!mounted) return;
                Navigator.of(context).pop();
              },
              child: const Text('保存'),
            ),
          ],
        ),
      ),
    );
  }
}

** **

lib/pages/event_detail_page.dart

import 'package:flutter/material.dart';
import '../local_db.dart';
import '../models.dart';

/// イベント詳細/コメント/参加表明
class EventDetailPage extends StatefulWidget {
  final LocalDb db;
  final Event event;
  const EventDetailPage({super.key, required this.db, required this.event});
  @override
  State<EventDetailPage> createState() => _EventDetailPageState();
}

class _EventDetailPageState extends State<EventDetailPage> {
  final _commentCtrl = TextEditingController();

  @override
  Widget build(BuildContext context) {
    final e = widget.event;
    final name = widget.db.displayName ?? '匿名';
    final goings = widget.db.listGoings(e.id);
    final isGoing = widget.db.isGoing(e.id, name);

    return Scaffold(
      appBar: AppBar(title: const Text('イベント詳細')),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          Text(
            e.title,
            style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.bold),
          ),
          const SizedBox(height: 8),
          Text(
            '${_fmtDate(e.startAt)}  ${_fmtTimeRange(context, e)}  ・  ${e.location ?? "場所未設定"}',
            style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey[700]),
          ),
          const SizedBox(height: 8),
          Text(e.description),
          const SizedBox(height: 16),
          Row(
            children: [
              const Icon(Icons.people_alt, size: 18),
              const SizedBox(width: 6),
              Text('参加予定: ${goings.length}人'),
              const Spacer(),
              FilledButton.tonalIcon(
                icon: Icon(isGoing ? Icons.check : Icons.event_available),
                label: Text(isGoing ? '参加取り消し' : '参加表明する'),
                onPressed: () async {
                  await widget.db.toggleRsvp(eventId: e.id, userName: name);
                  setState(() {});
                },
              ),
            ],
          ),
          const Divider(height: 32),
          Text('コメント', style: Theme.of(context).textTheme.titleMedium),
          const SizedBox(height: 8),
          ...widget.db.listComments(e.id).map(
            (c) => ListTile(
              dense: false,
              leading: const CircleAvatar(child: Icon(Icons.person)),
              title: Text(c.authorName, style: const TextStyle(fontWeight: FontWeight.w600)),
              subtitle: Text(c.message),
              trailing: Text(_fmtTime(context, c.createdAt), // BuildContext を渡す
                  style: const TextStyle(fontSize: 12, color: Colors.grey)),
            ),
          ),
          const SizedBox(height: 8),
          TextField(
            controller: _commentCtrl,
            minLines: 1,
            maxLines: 4,
            decoration: InputDecoration(
              hintText: 'コメントを書く…',
              border: const OutlineInputBorder(),
              suffixIcon: IconButton(
                icon: const Icon(Icons.send),
                onPressed: () async {
                  final text = _commentCtrl.text.trim();
                  if (text.isEmpty) return;
                  await widget.db.addComment(eventId: e.id, authorName: name, message: text);
                  _commentCtrl.clear();
                  setState(() {});
                },
              ),
            ),
          ),
          const SizedBox(height: 24),
        ],
      ),
    );
  }

  String _fmtDate(DateTime d) => '${d.year}/${d.month}/${d.day}';
  String _fmtTime(BuildContext ctx, DateTime t) => TimeOfDay.fromDateTime(t).format(ctx);
  String _fmtTimeRange(BuildContext ctx, Event e) {
    final s = _fmtTime(ctx, e.startAt);
    if (e.endAt == null) return s;
    return '$s - ${_fmtTime(ctx, e.endAt!)}';
  }
}

動作確認

  • 初回起動で「表示名」を保存できる
  • カレンダーが表示され、月送り矢印が ? にならない(対策済)
  • 「イベントを作成」でデータ保存 → その日を選ぶと一覧に出る
  • 詳細画面で参加表明のON/OFFができる
  • コメントを投稿すると、新しい順で並ぶ
  • オフラインでも前に入れたデータが見える