なぜ必要?
学校・地域・サークルでは、
- 「いつどこでイベントがあるのか分からない」
- 「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,
});
}
/// 役割: 参加表明(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ができる
- コメントを投稿すると、新しい順で並ぶ
- オフラインでも前に入れたデータが見える
