【Flet】カレンダーアプリを作成

# はじめに

本記事では、以下の記事を参考にさせていただき作成したものです。

https://qiita.com/kiiiiwy/items/80810524665f00cc75bb

こちらの内容について以下の要素を追加しました：

* 現在のFletのバージョン(0.25.2)での記法に
* 祝日や休日については色を付ける
* 入力した月のカレンダーを表示

↓ 今回作成したアプリ ↓

![スクリーンショット 2025-02-04 231700.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3275023/9b3ee0cd-6f04-a0c9-5d79-956005f322d2.png)

※このカレンダーによると今年は5/7,5/8,5/9を休みにすれば、9連休にすることが可能ですね。

# 1. 準備

日本の祝日についてはAPIなど利用できるものはいくつかありますが、今回は以下のCSVを`calendarApp.py`ファイルと同階層に配置し、こちらの`syukujitsu.csv`を読み取ります。

https://www8.cao.go.jp/chosei/shukujitsu/syukujitsu.csv

# 2. コードの簡単な説明

基本的なFletアプリの呼び出し方法に基づき、`main`関数を定義：

```python:main関数
def main(page: Page):
    page.title = 'Calendar View'
    page.window.min_width = 460
    page.window.width = 460
    page.add(MyCalendar())


if __name__ == '__main__':
    ft.app(main)
```

`MyCalendar`クラスに必要なコンポーネントを詰めて定義：

```python:MyCalendar
class MyCalendar(Column):
    def __init__(self):
        super().__init__()
        ....中略....

    def btn_clicked(self, e: ControlEvent):
        ....中略....

    def last_month_clicked(self, e: ControlEvent):
        ....中略....

    def next_month_clicked(self, e: ControlEvent):
        ....中略....

    def calendar_update(self, year: int, month: int):
        ....中略....

    def before_update(self):
        ....中略....
        
```

* btn_clicked : 「更新」ボタンを押下したらコンポーネントの更新を行う
* last_month_clicked : 先月のカレンダーを表示させる
* next_month_clicked : 翌月のカレンダーを表示させる
* calendar_update : ドロップダウンリスト（年と月）の値を取得し、対応する月のカレンダーを表示させる
* before_update : コンポーネントの更新が行われる前に呼び出される関数。ドロップダウンリスト（年と月）の値を取得し、`calendar_update`の引数に取得した年と月を渡して呼び出す

https://flet.dev/docs/getting-started/custom-controls/#before_update

他、`GridView`初期化用のクラスと`GridView`のItem用のクラスを定義しています。

# 3. 最終的なコード

```python: calendarApp.py
import os
import csv
import flet as ft
from datetime import datetime, timezone, timedelta
from calendar import Calendar
from flet import (
    alignment,
    border,
    border_radius,
    Container,
    ControlEvent,
    Colors,
    Column,
    Dropdown,
    dropdown,
    FontWeight,
    GridView,
    Icon,
    Icons,
    MainAxisAlignment,
    Row,
    Text,
    TextAlign,
    TextButton,
    padding,
    Page,
)

weeks = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
datetime_now = datetime.now(timezone(timedelta(hours=9), 'JST')).strftime('%Y-%m-%d %H:%M:%S')
dir_path = os.path.dirname(__file__)

with open(os.path.join(dir_path, 'syukujitsu.csv'), 'r', encoding='shift-jis') as r:
    read_holidays = [row for row in csv.reader(r)]


class MyCalendar(Column):
    def __init__(self):
        super().__init__()
        self._today = datetime.now(timezone(timedelta(hours=9), 'JST'))
        self._years = [i for i in range(self._today.year-20, self._today.year + 20 + 1)]  # 現在より20年前から20年後までを表示
        self._months = [i for i in range(1, 12 + 1)]  # 1月から12月まで
        self._year_dropdown = Dropdown(
            value=self._today.year,
            label='Year',
            options=[dropdown.Option(str(i)) for i in self._years],
            border=None,
            width=160,
            text_size=16,
        )
        self._month_dropdown = Dropdown(
            value=self._today.month,
            label='Month',
            options=[dropdown.Option(str(i)) for i in self._months],
            border=None,
            width=160,
            text_size=16,
        )
        self._this_month = Text(
            width=240, text_align=TextAlign.CENTER, size=18, weight=FontWeight.W_700, color=Colors.GREY_700
        )
        self._input_fields = Row(
            [
                self._year_dropdown, self._month_dropdown,
                TextButton(text='更新', width=60, on_click=self.btn_clicked,),
            ],
            alignment=MainAxisAlignment.END,
            width=420,
        )
        self._titles = Row(
            [
                TextButton(
                    content=Row([Icon(Icons.ARROW_LEFT), Text('先月')], spacing=1),  # Icon -> Text の順で表示
                    width=80,
                    on_click=self.last_month_clicked,
                ),
                self._this_month,
                TextButton(
                    content=Row([Text('翌月'), Icon(Icons.ARROW_RIGHT)], spacing=1),  # Text -> Icon の順で表示
                    width=80,
                    on_click=self.next_month_clicked,
                ),
            ],
            alignment=MainAxisAlignment.SPACE_BETWEEN,
            width=420,
        )

        self._calendar_grid = CalendarGridView()
        self._calendar_obj = Calendar(firstweekday=6)

        self.controls = [
            self._input_fields,
            self._titles,
            self._calendar_grid,
        ]

    def btn_clicked(self, e: ControlEvent):
        self.update()

    def last_month_clicked(self, e: ControlEvent):
        input_year = self._year_dropdown.value
        input_month = self._month_dropdown.value
        if (input_month - 1 <= 0):  # 1月の場合、昨年の12月を返す
            input_year -= 1
            input_month = 12
        else:
            input_month -= 1
        self._year_dropdown.value = input_year
        self._month_dropdown.value = input_month
        self.update()

    def next_month_clicked(self, e: ControlEvent):
        input_year = int(self._year_dropdown.value)
        input_month = int(self._month_dropdown.value)
        if (input_month + 1 > 12):  # 12月の場合、翌年の1月を返す
            input_year += 1
            input_month = 1
        else:
            input_month += 1
        self._year_dropdown.value = input_year
        self._month_dropdown.value = input_month
        self.update()

    def calendar_update(self, year: int, month: int):
        self._calendar_grid.controls.clear()  # GridView.Controlsの初期化
        holidays_filter = {
            x[0].split('/')[-1]: x[1]
            for x in read_holidays if f'{year}/{month}/' in x[0]
        }  # YYYY年MM月の休日を{ 'day' : 'Holiday Name' ,...}の形式で抽出
        for w in weeks:
            self._calendar_grid.controls.append(CalendarItem(w))
        for week in self._calendar_obj.monthdayscalendar(int(year), int(month)):
            for idx, day in enumerate(week):
                holiday = holidays_filter.get(str(day), '')
                match idx:
                    case 0:  # Sunは赤色
                        text_color = Colors.RED_ACCENT
                    case 6:  # Satは青色
                        text_color = Colors.BLUE_ACCENT
                    case _:  # 祝日の場合は赤色、それ以外はグレー
                        text_color = Colors.RED_ACCENT if holiday else Colors.GREY_500
                self._calendar_grid.controls.append(CalendarItem(day, holiday, text_color))

    def before_update(self):
        input_year = self._year_dropdown.value
        input_month = self._month_dropdown.value
        if input_year and input_month:
            self._this_month.value = f'{input_year}年{input_month}月'
            self.calendar_update(input_year, input_month)


class CalendarItem(Container):
    def __init__(self, day: int | str, holiday: str = '', text_color: str = Colors.GREY_500):
        super().__init__()
        self.day = str(day) if day != 0 else ''
        self.holiday = holiday if holiday != '休日' else '振替休日'
        self.text_color = text_color
        self.padding = padding.all(5)
        self.border = border.all(0.1)
        self.border_radius = border_radius.all(10)
        self.alignment = alignment.center
        self._day = Text(
            self.day, color=self.text_color, weight=FontWeight.W_700, width=50, text_align=TextAlign.CENTER
        )
        self._holiday = Text(self.holiday, width=50, text_align=TextAlign.CENTER, size=9, color=Colors.GREY)
        self.content = Column(
            controls=[self._day, self._holiday],
            spacing=1,
        )


class CalendarGridView(GridView):
    def __init__(self, item_width: int = 60):
        super().__init__()
        self.item_width = item_width
        self.width = self.item_width * 7
        self.max_extent = self.item_width
        self.child_aspect_ratio = 1.0
        self.spacing = 1
        self.run_spacing = 1


def main(page: Page):
    page.title = 'Calendar View'
    page.window.min_width = 460
    page.window.width = 460
    page.add(MyCalendar())


if __name__ == '__main__':
    ft.app(main)

```

# 4. 最後に

@kiiiiwy さんの記事から着想を得て今回のカレンダーアプリを作成いたしました。
カレンダーの週表示は鉛直方向に対して中央ぞろえにしたほうがいいなど気になる点はありますがカレンダーアプリとして満足のいくものが作れたかなと思います。（あとは、祝日データを自動更新できればもっといいですね。）