Fletで実践的なアプリ作成に向けて！Googleアカウント認証と表示制御から

先月あたりから**Flet**というPython × Flutterのライブラリについて学習を始めました。

https://flet.dev/

実際に簡単なハンズオンを行って記事を公開したところ、想像より遥かに多い方々に記事をみていただけて、驚きを隠せぬまま今回の記事作成に至っています！

https://qiita.com/eno49conan/items/b34e435453148a9f2323

これまではFletの基本的な文法や、簡単な機能を持つ画面を何個か作ってきました。
**今回はもう少し本格的なアプリケーション作成をしたい**と思って、始めました。
全部完成してから、公開！という手もあったのですが、多分まとめる作業がかなりボリューミーになりそう。。。だったので、
少しずつ公開して丁寧なアウトプットにしたいと思います。

**続編**
[Fletで実践的なアプリ作成に向けて！セミナー運営サイトの設計・実装を進める](https://qiita.com/eno49conan/items/17c45aea21062bfad353) ← **7/20に公開済です！**

# 作成してみたもの
今回作成したのが以下画像の通りです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/e7d3bf8d-0c31-ba4b-21bf-df0c364e422e.png)

■**デモサイト**
~~今回作成したアプリケーションのデモサイトです。機能数はまだ少ないないため、なんとなくポチポチしていただければ、どんな機能があるか把握いただけると、と思います！~~
**都合により、現在は表示できない状態になっています。**

https://seminar-app.fly.dev/

### 作成した機能
レイアウトのベースは既にあるので、利用しつつ、以下の機能を作成してみました。
- **light/darkモードの切り替え**
    - [参考動画](https://www.youtube.com/watch?v=MsV2q9zvG6k)
- **googleアカウントによるログイン/ログアウト機能**
    - [参考動画](https://www.youtube.com/watch?v=t9ca2jC4YTo)
- **ログイン/未ログインに応じて、画面の表示内容制御**
    - こちらは、土台としていたサンプルを元に自分になりアレンジしました！

今回は、画面上部の青い部分と左側のタブに関する説明が中心です。
画面右側（※）は、これから具体的に作り上げますので、特に説明は記載していません。今回は、今後使うかな～～と考えているコンテンツを仮で配置しています。
今回の説明は特に記載していません。
※作成してみたもの、でお見せした画像の、「一覧画面」といったタイトルやその下のカード表示

また、以下サンプルを土台に作成を開始しました。
同じようなレイアウト、であると確認いただけると思います。この土台に手を加えて、先ほど表示した画面レイアウトになりました。

https://github.com/flet-dev/examples/tree/main/python/community/responsive_menu_layout

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/c0119cb8-5fd3-2a28-83d6-5d6878c021ba.png)

# 実装
まずは、画面要素を作り上げるために必要な実装について記載します。
その後、画面に表示した要素に取り付けた機能を実現するための実装について記載しています。

### 画面要素の作成
##### 土台作成
画面要素の構築を進めていきます。最初は本当に土台です。（起動して問題ないか確認する程度）
```python:main.py
import flet as ft
from flet import (
    Text
)
if __name__ == "__main__":
    def main(page: Page, title="Basic Responsive Menu"):
        page.title = title
        page.theme_mode = "light"
        page.add(Text(f"My Flet Sample App"))
    ft.app(target=main, port=8550, view=ft.WEB_BROWSER)
```
画面にテキストを表示するだけですね。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/5257ea59-6073-c289-592f-d83825ef0532.png)
画面に要素を追加する場合は、以下を記述します。
```
page.add(追加したい内容)
```
##### AppBar追加
Appbarの先頭（左端）の表示内容、タイトルの中央寄せ、AppBarの高さ・背景色などを定義します。
今回は`page.add()`は使ってませんね。
AppBarについて、`page.appbar`というプロパティが、Pageクラスにフィールドが用意されているので、それを使います。`page.XXX`のXXXは色々用意されています。以下リンクでその数が確認できますが、かなり充実してます！！

https://flet.dev/docs/controls/page/

```python
if __name__ == "__main__":
    def main(page: Page, title="Basic Responsive Menu"):
        page.title = title
        # 略・・・
        # AppBarを追加
        menu_button = IconButton(icons.MENU, icon_color=ft.colors.WHITE)
        page.appbar = AppBar(
            title=Text(f"My Flet Sample App", size=32, color=ft.colors.WHITE),
            leading=menu_button,
            leading_width=40,
            center_title=True,
            toolbar_height=70,
            bgcolor=ft.colors.BLUE_ACCENT_700
        )
    ft.app(target=main, port=8550, view=ft.WEB_BROWSER)
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/6b81fe9d-f08f-8711-20b3-c242fe6c7c1f.png)
##### サイドバー追加
他のファイルで定義したクラスにpage情報を渡して、表示内容や表示内容に関するアクション・制御に必要な関数を設定します。
またpages配列は各タブの情報（アイコンとテキスト）を定義しています。
※他ファイルのコードはここでは割愛します。この後のGoogle認証時の表示制御で記載します。

```python
from layout import ResponsiveMenuLayout
if __name__ == "__main__":
    def main(page: Page, title="Basic Responsive Menu"):
        page.title = title
        # 略・・・
        # AppBarを追加
        # 略・・・
        pages = [
        # 各タブの表示内容（略）
        ]
        menu_layout = ResponsiveMenuLayout(page, pages)
        page.add(menu_layout)
        menu_button.on_click = lambda e: menu_layout.toggle_navigation()
    ft.app(target=main, port=8550, view=ft.WEB_BROWSER)
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/a8f1dd15-f12e-1893-011d-075b3565b3c5.png)
アイコンクリック時
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/3f952609-c7f6-39b1-7dcb-4a18c0be194c.png)

##### AppBarにアイコンとボタン追加
light & darkモードの切り替えアイコンとGoogle認証のボタンについて要素を追加します。こちらも別ファイルで必要な要素と関数を定義して、そのクラスを呼び出します。
```python
from layout import ResponsiveMenuLayout
from google_auth import GoogleOAuth
from switch_right_dark import ToggleDarkLight
if __name__ == "__main__":
    def main(page: Page, title="Basic Responsive Menu"):
        page.title = title
        # 略・・・
        menu_button.on_click = lambda e: menu_layout.toggle_navigation()
        # AppBarの表示要素追加
        page.appbar.actions = []
        # light dark 切り替え要素
        ToggleDarkLight(page, page.appbar.actions)
        # Google認証要素追加
        GoogleOAuth(page, page.appbar.actions)
    ft.app(target=main, port=8550, view=ft.WEB_BROWSER)
```
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/57344d34-09d7-9763-0215-09eefcaaee61.png)

### 機能面の実装
これまでは、画面に対して要素を追加する方法を記載してきました。画面に対して要素を追加するだけであれば、難易度はそこまで上がらないと思います。実際に**各要素に対してアクションを加える始めると、色々考えるようになると感じました。**

##### light darkモード切替以外にも機能を追加しよう
[Fletの公式ドキュメント](https://flet.dev/)に近い形にしようと思って進めました。
画面の背景色が変わるので、機能しているかどうかは、ボタンを押したときに判断しやすいです。
ですが切り替え以外にも、アイコンの切り替えやToolTip（マウスを載せたときの表示文字）をつけたいです。以下画像が作成してみたものです。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/6acbb4cc-d872-402f-a9f3-4dd1210cd0d0.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/edee3d80-fce1-5909-6452-753726558e64.png)

```python:switch_right_dark.py
import flet as ft
from flet import (
    Container,
    IconButton,
    Page,
    ButtonStyle,
    ProgressBar,
)
from time import sleep

class ToggleDarkLight():
    def __init__(
        self,
        page: Page,
        contents: list,
        * args,
        **kwargs,
    ):
        super().__init__(*args, **kwargs)
        self.page = page

        # 切り替え時にプログレスバー表示
        page.splash = ProgressBar(visible=False)

        def change_theme(e):
            # プログレスバー表示
            page.splash.visible = True
            page.theme_mode = "light" if page.theme_mode == "dark" else "dark"
            page.update()

            # アニメーション適用のための時間確保(1s)
            sleep(1)

            # アイコンの選択状態
            toggle_dark_light.selected = not toggle_dark_light.selected
            # ToolTipの表示文言更新
            toggle_dark_light.tooltip = f"switch light and dark mode (currently {'dark' if toggle_dark_light.selected else 'light'} mode)"
            # プログレスバー非表示
            page.splash.visible = False
            page.update()

        # 切り替え用のボタン
        toggle_dark_light = IconButton(
            # ToolTipの表示文言
            tooltip=f"switch light and dark mode (currently light mode)",
            # アイコンクリック時に実行する処理
            on_click=change_theme,
            icon="light_mode",
            selected_icon="dark_mode",
            style=ButtonStyle(
                # change color if light and dark
                color={"": ft.colors.ORANGE_300,
                       "selected": ft.colors.YELLOW},
            )
        )

        contents.append(
            Container(
                content=toggle_dark_light,
                margin=ft.margin.only(right=10)
            )
        )

```
:::note 

**`page.add(x)`と`page.update()`** の違いについて

ここまでの実装紹介で、`page.add(x)`と`page.update()`が登場しています。
何が異なるのかという話ですが、実は`page.add(x)`は`page.update()`も実施しています。
チュートリアルのコードのコメントで以下のように記載されています。
```
page.add(t) # it's a shortcut for page.controls.append(t) and then page.update()
```
`page.controls.append(t)`は画面のある要素に対して、何か(t)を追加することを指します。
追加した上で、画面の状態を更新(`page.update()`)します。
`page.update()`はlight darkモードでもそうですが、何か要素を追加する操作はなく、
既存の要素を使って、画面の状態を更新しています。
:::

https://flet.dev/docs/guides/python/getting-started

##### Google認証機能を実装してみよう

**Google Cloudの設定**
OAuthのクライアントIDを作成します。ポート番号8550はfletアプリケーション起動時に指定している番号と同じです。作成した際の、`client ID`と`client Secret`が発行されるので、その値は実装時に使用します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/ea7b74a1-56df-ea90-d250-372bae39eb2d.png)
**画面操作**
**認証前は以下のように、タブが2つされた状態です。**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/586640e7-90de-e7ec-3ee0-bb0533e18fb0.png)
ログインボタンをクリックすると、アカウント選択のウインドウが立ち上がるので、アカウントを選択して、ログイン処理を実行します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/f87e80e6-e974-12ca-ea95-1d6c8815db44.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/525cf54e-c5ff-14f4-5a17-aae7a9866b02.png)
**Sign in からSign outにボタンが変わり、表示されるタブが4つに増えました！！**
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/c85b9093-1ea1-bd67-6560-4429aecc4ab7.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/ca7169fe-ac80-5b65-9d9b-812b608082bf.png)

画像を見ていただいた上で、以下の機能を実装しています。
- ボタン(ログイン/ログアウト)の差し替え
- 認証前後で表示タブの切り替え
`client ID`と`client Secret`や`Redirect Url`は環境変数のファイルに設定して、読み込む形にしています。

```python:google_oauth.py
import flet as ft
from flet import (
    Container,
    ElevatedButton,
    Page,
)
from flet.auth.providers.google_oauth_provider import GoogleOAuthProvider
import os
from dotenv import load_dotenv
load_dotenv()

ClientID = os.getenv('ClientID')
ClientSecret = os.getenv('ClientSecret')
RedirectUrl= os.getenv('RedirectUrl')

class GoogleOAuth():
    def __init__(
        self,
        page: Page,
        contents: list,
        * args,
        **kwargs,
    ):
        super().__init__(*args, **kwargs)

        self.page = page

        # GoogleOAuthのProvider定義
        provider = GoogleOAuthProvider(
            client_id=ClientID,
            client_secret=ClientSecret,
            redirect_url=RedirectUrl
        )

        # ログイン処理
        def login_google(e):
            self.page.login(provider)

        # ログアウト処理
        def logout_google(e):
            self.page.logout()

        # ログインボタン
        login_button = Container(
            content=ElevatedButton(
                "Sign in Google", bgcolor=ft.colors.LIGHT_BLUE_500, color=ft.colors.WHITE, on_click=login_google,
            ),
            margin=ft.margin.only(right=10)
        )

        # ログアウトボタン
        logout_button = Container(
            content=ElevatedButton(
                "Sign out Google", bgcolor=ft.colors.RED_300, color=ft.colors.WHITE, on_click=logout_google),
            margin=ft.margin.only(right=10)
        )

        def on_login(e):
            print(page.auth.user['name'], page.auth.user['email'])
            contents.pop()
            # 画面に表示するボタンを「ログアウト」ボタンに
            log_inout_button = logout_button
            contents.append(log_inout_button)
            page.update()
            page.go('/')

        def on_logout(e):
            contents.pop()
            log_inout_button = login_button
            contents.append(log_inout_button)
            page.update()
            page.go('/logout')

        page.on_login = on_login
        page.on_logout = on_logout
        # 画面に表示するボタンを「ログイン」ボタンに
        log_inout_button = login_button
        contents.append(log_inout_button)

# reference
# https://www.youtube.com/watch?v=t9ca2jC4YTo

```

##### Google認証前後で表示内容を制御してみよう
こちらは、実装を割愛すると中盤で話した、`ResponsiveMenuLayout`の話になります。
`ResponsiveMenuLayout`クラス内部に、関数`_on_route_change`を定義しています。画面上で、タブを切り替えるたびに実行する処理です。
また関数で`update_destinations`で表示タブの制御をしています。肝心なのは`self.page.auth`です。
この値は、Google認証前は`None`ですが、認証後はユーザ名やメールアドレス情報が格納されます。（None以外になる）
この値の違いを利用して、画面の表示タブを制御する機能を加えました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/345414/995e6c13-cee8-70fc-92ad-729a7220c1f1.png)
**アイコン検索**
各タブで表示しているアイコンは以下で検索しました。[Flet公式ページのGallery](https://flet.dev/gallery)からアクセスできます。

https://gallery.flet.dev/icons-browser/

```python:layout.py
class ResponsiveMenuLayout(Row):
    def __init__(
        self,
        page: Page,
        pages,
        support_routes=True,
        *args,
        **kwargs,
    ):
        super().__init__(*args, **kwargs)
        self.page = page
        
        # 略・・・・・
        if support_routes:
            self._route_change(page.route)
            # パスが更新される度に実行する処理を設定
            self.page.on_route_change = self._on_route_change
        self._change_displayed_page()

        # パスが更新される度に実行する処理
        def _on_route_change(self, event):
            # タブの表示項目を更新する処理を呼び出し
            self.update_destinations()
            self._route_change(event.route)
            self.page.update()

        # タブの表示項目を更新する処理
        def update_destinations(self):
            navigation_items = self.navigation_items
    
            if self.page.auth is None:
                _navigation_items = []
                _navigation_items.append(navigation_items[0])
                _navigation_items.append(navigation_items[1])
                navigation_items = _navigation_items
    
            self.navigation_rail.destinations = [
                NavigationRailDestination(**nav_specs) for nav_specs in navigation_items
            ]
```

以上が機能に関する実装の紹介でした！

# 感想と今後
今回の取り組みを通じて、レイアウト構築やログイン状態に応じた画面制御に関する実装方法を知ることができました。
特に、表示タブの制御を自力で実装できたのは、**既存の実装(土台にしていたコード)
を理解できている**といえる部分があるので、良かったと思います。

次のステップとして、
各画面の機能を検討し、画面の実装やデータベースのテーブル定義などを進めていきたいと思います。