なぜ必要?
学校・地域・サークルでは、
- 「いつどこでイベントがあるのか分からない」
- 「LINEに流れて消えた」
- 「SNSはやっていない人もいる」
という問題がよく起きます。この問題をアプリで解決してみましょう。
課題設定(問題をはっきりさせる)
- 情報が散らばって見つけにくい
- 直近の予定が一目で分からない
- 参加人数が把握しづらい
- ネットが不安定だと見られない/書けない
要件定義(解決のためのルール)
機能要件
- F1: イベントを作成できる(タイトル/説明/日付・時間/場所)
- F2: イベントにコメントできる
- F3: 参加表明(参加/取り消し)ができる
- F4: カレンダーで日付ごとにイベント一覧が見える
- F5: オフライン保存(端末ローカルDB)で動く
非機能要件
- N1: 操作はシンプル(中学生でも迷わない)
- N2: 起動が速い(ローカルDB)
- N3: プライバシー配慮(個人情報は名前のみ。外部送信なし)
- N4: iOS/Androidどちらでも動作(Flutter)
設計(どう作る?)
技術選定
- Flutter:1つのコードで iOS/Android 両対応
- Hive:端末内に軽量データ保存(オフラインOK)
- table_calendar:カレンダー表示
画面設計
- 表示名設定…はじめにニックネームを保存
- ホーム…カレンダー+その日のイベント一覧
- イベント作成…タイトル/説明/日時/場所を入力
- イベント詳細…内容+参加表明+コメント一覧/投稿
データ設計(シンプルな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,
});
}
@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,
});
}
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';
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),
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ができる
- コメントを投稿すると、新しい順で並ぶ
- オフラインでも前に入れたデータが見える
なぜ必要?
学校・地域・サークルでは、
課題設定(問題をはっきりさせる)
要件定義(解決のためのルール)
機能要件
非機能要件
設計(どう作る?)
技術選定
画面設計
データ設計(シンプルな3テーブル)
関係性
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: truelib/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!)}'; } }動作確認