FlutterでAirbnb風の予約カレンダーを実装する方法
みなさん、こんにちは!今回はFlutterでAirbnb風の予約カレンダーを作成する方法を紹介します。TableCalendarライブラリを利用し、最低宿泊日数の制御や料金計算などを組み込んだ実用的なカレンダーを実装してみました。
📅 実装のポイント
- 予約可能・不可能日を判定
- 最低宿泊日数を制御
- 料金を日ごとに表示&合計計算
- 選択範囲のバリデーション(不可能日を含むかどうか)
🛠 コード全体と解説
以下がAirbnb風の予約カレンダーを実装するFlutterコードです。
実運用しやすい構成になっています。
// 必要なインポート
import 'package:flutter/material.dart';
import 'package:table_calendar/table_calendar.dart';
import 'package:intl/intl.dart';
class AirbnbStyleCalendarCustomWidget extends StatefulWidget {
const AirbnbStyleCalendarCustomWidget({
super.key,
this.width,
this.height,
required this.availabilityJson, // ✅ 利用可能日と価格を含むデータ
required this.minStayDays, // ✅ 最低宿泊日数
required this.bookingParameterAction, // ✅ 選択結果を渡す処理
});
final double? width;
final double? height;
final List<dynamic> availabilityJson;
final int minStayDays;
final Future Function(BookingParameterStruct bookingParameter)
bookingParameterAction;
@override
State<AirbnbStyleCalendarCustomWidget> createState() =>
_AirbnbStyleCalendarCustomWidgetState();
}
class _AirbnbStyleCalendarCustomWidgetState
extends State<AirbnbStyleCalendarCustomWidget> {
DateTime? _start; // ✅ チェックイン日
DateTime? _end; // ✅ チェックアウト日
DateTime _focusedDay = DateTime.now();
late final Map<String, Map<String, dynamic>> availabilityMap;
@override
void initState() {
super.initState();
// ✅ availabilityJson を {日付文字列: 情報} の形に変換して管理
availabilityMap = {
for (var item in widget.availabilityJson)
item['date']: item as Map<String, dynamic>,
};
}
// ✅ 指定日が予約可能かどうか
bool _isAvailable(DateTime date) {
final key = DateFormat('yyyy-MM-dd').format(date);
return availabilityMap[key]?['is_available'] == true;
}
// ✅ 指定日の価格を取得
String? _getPrice(DateTime date) {
final key = DateFormat('yyyy-MM-dd').format(date);
final price = availabilityMap[key]?['price_override'];
return price != null ? price.toString() : null;
}
// ✅ 最低宿泊日数を満たしているか判定
bool _isRangeAllowed(DateTime start, DateTime end) {
return end.difference(start).inDays >= widget.minStayDays;
}
// ✅ 選択範囲に含まれるかどうか
bool _isInRange(DateTime day) {
if (_start == null) return false;
if (_end == null) return day.isAtSameMomentAs(_start!);
return (day.isAtSameMomentAs(_start!) || day.isAfter(_start!)) &&
(day.isAtSameMomentAs(_end!) || day.isBefore(_end!));
}
// ✅ 合計料金を計算
int _calculateTotalPrice(DateTime start, DateTime end) {
int total = 0;
DateTime current = start;
while (current.isBefore(end)) {
final key = DateFormat('yyyy-MM-dd').format(current);
final price = availabilityMap[key]?['price_override'];
if (price is int) {
total += price;
}
current = current.add(const Duration(days: 1));
}
return total;
}
@override
Widget build(BuildContext context) {
return Container(
width: widget.width,
height: widget.height,
padding: const EdgeInsets.all(8.0),
child: TableCalendar(
headerStyle: HeaderStyle(
formatButtonVisible: false, // ✅ 月表示切替ボタンを非表示
),
firstDay: DateTime.now(),
lastDay: DateTime.now().add(const Duration(days: 365)),
focusedDay: _focusedDay,
rangeStartDay: _start,
rangeEndDay: _end,
rangeSelectionMode: RangeSelectionMode.disabled, // ✅ 手動で範囲を管理
enabledDayPredicate: (date) => _isAvailable(date), // ✅ 予約可能日だけ選択可能
// ✅ 日付をタップした時の処理
onDaySelected: (selectedDay, focusedDay) {
setState(() {
if (_start == null || (_start != null && _end != null)) {
// ✅ 最初の選択 or 再選択 → start をセット
_start = selectedDay;
_end = null;
} else if (_start != null && _end == null) {
// ✅ 2回目の選択 → end をセット
if (selectedDay.isBefore(_start!)) {
// ✅ チェックアウト日がチェックイン日より前ならリセット
_start = selectedDay;
_end = null;
} else {
_end = selectedDay;
// ✅ 範囲内に予約不可日が含まれていないか確認
DateTime current = _start!;
while (!current.isAfter(_end!)) {
if (!_isAvailable(current)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('選択範囲に予約不可日が含まれています。'),
duration: Duration(seconds: 4),
backgroundColor: Colors.red,
),
);
_start = null;
_end = null;
return;
}
current = current.add(const Duration(days: 1));
}
// ✅ 最低宿泊日数を満たしているか確認
if (!_isRangeAllowed(_start!, _end!)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('最低 ${widget.minStayDays} 泊以上を選択してください。'),
duration: const Duration(seconds: 4),
backgroundColor: Colors.red,
),
);
_start = null;
_end = null;
return;
}
// ✅ 合計料金と宿泊日数を計算
final totalPrice = _calculateTotalPrice(_start!, _end!);
final totalDay = _end!.difference(_start!).inDays;
// ✅ 選択結果を外部に渡す
final booking = BookingParameterStruct(
checkInDate: _start!,
checkOutDate: _end!,
totalDay: totalDay,
totalPrice: totalPrice,
);
widget.bookingParameterAction(booking);
}
}
_focusedDay = focusedDay;
});
},
// ✅ カレンダーセルのカスタマイズ
calendarBuilders: CalendarBuilders(
defaultBuilder: (context, day, _) {
final isAvailable = _isAvailable(day);
final price = _getPrice(day);
final isSelected = _isInRange(day);
return Container(
margin: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: _isInRange(day)
? Colors.blue.shade100 // ✅ 選択中の範囲は青背景
: isAvailable
? Colors.white // ✅ 利用可能日は白
: Colors.grey.shade300, // ✅ 利用不可日はグレー
borderRadius: BorderRadius.circular(10),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'${day.day}',
style: TextStyle(
color: isAvailable ? Colors.black : Colors.grey,
fontWeight: isSelected
? FontWeight.bold // ✅ 選択範囲は太字
: FontWeight.normal,
),
),
if (price != null)
Text(
'¥$price',
style: const TextStyle(
fontSize: 8,
color: Colors.green,
),
),
],
),
);
},
),
),
);
}
}
📱 実際の表示例
Airbnb風カレンダーUI
- 白背景の日 → 予約可能
- グレー背景の日 → 予約不可
- 青背景 → 選択中の範囲
- 価格も各日に表示
✅ まとめ
- TableCalendarを使えばAirbnb風の予約カレンダーを簡単に実装可能。
- 利用可能日・最低宿泊日数・価格表示を組み込むことで実用的に!
- 日本語メッセージでユーザーにわかりやすいエラーハンドリングができる。
Airbnb風の予約体験をFlutterで再現したい方は、ぜひ試してみてください! 🚀