Fletを触ってみる。

# はじめに
社内のハッカソンが開催されたので、そこでFletライブラリを触ってみました。
https://flet.dev


# Fletとは？
Fletは、フロントエンド開発の経験がなくても、Web、デスクトップ、モバイルアプリを好みの言語で構築できるクロスプラットフォームフレームワークです。


## 特徴
GoogleのFlutterをベースに作られており、以下の6つの特徴があります。

1. アイデアからアプリまでが迅速
内部ツール、チーム用ダッシュボード、週末プロジェクト、データ入力フォーム、キオスクアプリ、高忠実度プロトタイプなど、Fletは迅速にインタラクティブなアプリを作成するのに適しています。

1. シンプルなアーキテクチャ
JavaScriptフロントエンド、REST APIバックエンド、データベース、キャッシュなどの複雑なアーキテクチャを必要とせず、Pythonだけでモノリス状のステートフルアプリを作成でき、マルチユーザー、リアルタイムのシングルページアプリケーション（SPA）を提供します。

1. バッテリー同梱
開発を始めるために必要なのはお気に入りのIDEまたはテキストエディターのみです。SDKや多数の依存関係、複雑なツールは必要ありません。Fletには、アセットホスティングとデスクトップクライアントを備えた内蔵Webサーバーがあります。

1. Flutterによる強化
Flet UIはFlutterを使って構築されており、プロフェッショナルな外観のアプリを任意のプラットフォームに提供できます。FletはFlutterモデルを単純化し、より小さな「ウィジェット」を即使用可能な「コントロール」に組み合わせ、命令型プログラミングモデルを採用しています。

1. 多言語対応
Fletは言語非依存なので、チームの誰もがお気に入りの言語でFletアプリを開発できます。現在はPythonがサポートされており、Go、C#などが次に追加される予定です。

1. あらゆるデバイスへの配信
FletアプリをWebアプリとしてデプロイし、ブラウザで表示することができます。Windows、macOS、Linux用のスタンドアロンデスクトップアプリとしてパッケージ化することもできます。モバイルではPWAとしてインストールするか、iOSとAndroid用のFletアプリで表示することができます


# 実際に触ってみる
今回はチュートリアルのソリティアをやってみます。
https://flet.dev/docs/tutorials/python-solitaire


## 環境について
MacBook Pro(2020)にPython 3.11.3を入れて試していきます。
ちなみにFletを動かすにはPython 3.8以降が必要だそうです


## インストール
```console
pip install flet
```


### hello world
はろわの実行

```python
import flet as ft


def main(page: ft.Page):
    page.add(ft.Text(value="Hello, world!"))


ft.app(target=main)
```

アプリが立ち上がって無事表示されました。
![スクリーンショット 2024-01-26 11.24.15.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3591134/7fb949cc-8d34-119c-cbdf-86592dd992ee.png)


## ソリティア ゲームの概念実証アプリ
細かくやると長くなるのでここからは一気に進めていきます。
気になる方は公式をチェックしてください。

カードの生成とカードを置くスロットを作ってドラッグ&ドロップできるようにして概念実証を行います。
```python
class Solitaire:
    def __init__(self):
        self.start_top = 0
        self.start_left = 0


def main(page: ft.Page):
    solitaire = Solitaire()

    def start_drag(e: ft.DragStartEvent):
        solitaire.start_top = e.control.top
        solitaire.start_left = e.control.left
        e.control.update()

    def drag(e: ft.DragUpdateEvent):
        e.control.top = max(0, e.control.top + e.delta_y)
        e.control.left = max(0, e.control.left + e.delta_x)
        e.control.update()

    def drop(e: ft.DragEndEvent):
        for slot in slots:
            if (abs(e.control.top - slot.top) < 20 and abs(e.control.left - slot.left) < 20):
                place(e.control, slot)
                e.control.update()
                return
        bounce_back(solitaire, e.control)
        e.control.update()

    def bounce_back(game, card):
        """カードをもとの位置に戻す処理"""
        card.top = game.start_top
        card.left = game.start_left
        page.update()

    def place(card, slot):
        """カードをスロットに置く処理"""
        card.top = slot.top
        card.left = slot.left
        page.update()

    # カード定義
    card1 = ft.GestureDetector(
        mouse_cursor=ft.MouseCursor.MOVE,
        drag_interval=5,
        on_pan_update=drag,
        on_pan_end=drop,
        left=0,
        top=0,
        content=ft.Container(bgcolor=ft.colors.GREEN, width=70, height=100),
    )
    card2 = ft.GestureDetector(
        mouse_cursor=ft.MouseCursor.MOVE,
        drag_interval=5,
        on_pan_start=start_drag,
        on_pan_update=drag,
        on_pan_end=drop,
        left=100,
        top=0,
        content=ft.Container(bgcolor=ft.colors.YELLOW, width=70, height=100),
    )

    # スロット定義
    slot0 = ft.Container(width=70, height=100, left=0, top=0, border=ft.border.all(1))
    slot1 = ft.Container(width=70, height=100, left=200, top=0, border=ft.border.all(1))
    slot2 = ft.Container(width=70, height=100, left=300, top=0, border=ft.border.all(1))
    slots = [slot0, slot1, slot2]
    controls = [slot0, slot1, slot2, card1, card2]
    page.add(ft.Stack(controls=controls, width=1000, height=500))


ft.app(target=main)
```

上記コードを実行すると以下のような動作になります。(画像は公式)
![](https://flet.dev/img/docs/solitaire-tutorial/drag_and_drop6.gif)


## ソリティアの完成
ルールを追加するだけなので、一気に最後まで飛ばします。


### 完成コード(長いので折りたたみます。)

<details>

```python
import flet as ft
import random


CARD_WIDTH = 70
CARD_HEIGTH = 100
DROP_PROXIMITY = 30
CARD_OFFSET = 20


class Card(ft.GestureDetector):
    def __init__(self, solitaire, suite, rank):
        super().__init__()
        self.mouse_cursor = ft.MouseCursor.MOVE
        self.drag_interval = 5
        self.on_pan_start = self.start_drag
        self.on_pan_update = self.drag
        self.on_pan_end = self.drop
        self.on_tap = self.click
        self.on_double_tap = self.doubleclick
        self.suite = suite
        self.rank = rank
        self.face_up = False
        self.top = None
        self.left = None
        self.solitaire = solitaire
        self.slot = None
        self.content = ft.Container(
            width=CARD_WIDTH,
            height=CARD_HEIGTH,
            border_radius=ft.border_radius.all(6),
            content=ft.Image(src='images/card_back.png'),
        )

    def turn_face_up(self):
        '''カードを表にする'''
        self.face_up = True
        self.content.content.src = f'images/{self.rank.name}_{self.suite.name}.svg'
        self.solitaire.update()

    def turn_face_down(self):
        '''カードを裏にする'''
        self.face_up = False
        self.content.content.src = 'images/card_back.png'
        self.solitaire.update()

    def move_on_top(self):
        '''カードの山をスタックの一番上に移動'''
        for card in self.get_draggable_pile():
            self.solitaire.controls.remove(card)
            self.solitaire.controls.append(card)
        self.solitaire.update()

    def bounce_back(self):
        '''移動中のカードを元の位置に戻す'''
        draggable_pile = self.get_draggable_pile()
        for card in draggable_pile:
            if card.slot in self.solitaire.tableau:
                card.top = card.slot.top + card.slot.pile.index(card) * CARD_OFFSET
            else:
                card.top = card.slot.top
            card.left = card.slot.left
        self.solitaire.update()

    def place(self, slot):
        '''ドラッグ可能な束をスロットに配置'''
        draggable_pile = self.get_draggable_pile()
        for card in draggable_pile:
            if slot in self.solitaire.tableau:
                card.top = slot.top + len(slot.pile) * CARD_OFFSET
            else:
                card.top = slot.top
            card.left = slot.left
            if card.slot is not None:
                card.slot.pile.remove(card)
            card.slot = slot
            slot.pile.append(card)
        if self.solitaire.check_win():
            self.solitaire.winning_sequence()
        self.solitaire.update()

    def get_draggable_pile(self):
        '''現在のカードから一緒にドラッグされるカードのリストを返す'''
        if (
            self.slot is not None
            and self.slot != self.solitaire.stock
            and self.slot != self.solitaire.waste
        ):
            return self.slot.pile[self.slot.pile.index(self):]
        return [self]

    def start_drag(self, e: ft.DragStartEvent):
        if self.face_up:
            self.move_on_top()
            self.solitaire.update()

    def drag(self, e: ft.DragUpdateEvent):
        if self.face_up:
            draggable_pile = self.get_draggable_pile()
            for card in draggable_pile:
                card.top = (
                    max(0, self.top + e.delta_y)
                    + draggable_pile.index(card) * CARD_OFFSET
                )
                card.left = max(0, self.left + e.delta_x)
                self.solitaire.update()

    def drop(self, e: ft.DragEndEvent):
        if self.face_up:
            for slot in self.solitaire.tableau:
                if (
                    abs(self.top - (slot.top + len(slot.pile) * CARD_OFFSET)) < DROP_PROXIMITY
                    and abs(self.left - slot.left) < DROP_PROXIMITY
                ) and self.solitaire.check_tableau_rules(self, slot):
                    self.place(slot)
                    self.solitaire.update()
                    return
            if len(self.get_draggable_pile()) == 1:
                for slot in self.solitaire.foundations:
                    if (
                        abs(self.top - slot.top) < DROP_PROXIMITY
                        and abs(self.left - slot.left) < DROP_PROXIMITY
                    ) and self.solitaire.check_foundations_rules(self, slot):
                        self.place(slot)
                        self.solitaire.update()
                        return
            self.bounce_back()
            self.solitaire.update()

    def click(self, e):
        if self.slot in self.solitaire.tableau:
            if not self.face_up and self == self.slot.get_top_card():
                self.turn_face_up()
                self.solitaire.update()
        elif self.slot == self.solitaire.stock:
            self.move_on_top()
            self.place(self.solitaire.waste)
            self.turn_face_up()
            self.solitaire.update()

    def doubleclick(self, e):
        if self.face_up:
            self.move_on_top()
            for slot in self.solitaire.foundations:
                if self.solitaire.check_foundations_rules(self, slot):
                    self.place(slot)
                    self.solitaire.update()
                    return


SLOT_WIDTH = 70
SLOT_HEIGHT = 100


class Slot(ft.Container):
    def __init__(self, solitaire, top, left, border):
        super().__init__()
        self.pile = []
        self.width = SLOT_WIDTH
        self.height = SLOT_HEIGHT
        self.left = left
        self.top = top
        self.on_click = self.click
        self.solitaire = solitaire
        self.border = border
        self.border_radius = ft.border_radius.all(6)

    def get_top_card(self):
        if len(self.pile) > 0:
            return self.pile[-1]

    def click(self, e):
        if self == self.solitaire.stock:
            self.solitaire.restart_stock()


SOLITAIRE_WIDTH = 1000
SOLITAIRE_HEIGHT = 500


class Suite:
    def __init__(self, suite_name, suite_color):
        self.name = suite_name
        self.color = suite_color


class Rank:
    def __init__(self, card_name, card_value):
        self.name = card_name
        self.value = card_value


class Solitaire(ft.Stack):
    def __init__(self):
        super().__init__()
        self.controls = []
        self.width = SOLITAIRE_WIDTH
        self.height = SOLITAIRE_HEIGHT

    def did_mount(self):
        self.create_card_deck()
        self.create_slots()
        self.deal_cards()

    def create_card_deck(self):
        suites = [
            Suite('hearts', 'RED'),
            Suite('diamonds', 'RED'),
            Suite('clubs', 'BLACK'),
            Suite('spades', 'BLACK'),
        ]
        ranks = [
            Rank('Ace', 1),
            Rank('2', 2),
            Rank('3', 3),
            Rank('4', 4),
            Rank('5', 5),
            Rank('6', 6),
            Rank('7', 7),
            Rank('8', 8),
            Rank('9', 9),
            Rank('10', 10),
            Rank('Jack', 11),
            Rank('Queen', 12),
            Rank('King', 13),
        ]

        self.cards = []

        for suite in suites:
            for rank in ranks:
                self.cards.append(Card(solitaire=self, suite=suite, rank=rank))

    def create_slots(self):
        self.stock = Slot(solitaire=self, top=0, left=0, border=ft.border.all(1))
        self.waste = Slot(solitaire=self, top=0, left=100, border=None)
        self.foundations = []
        x = 300
        for i in range(4):
            self.foundations.append(Slot(solitaire=self, top=0, left=x, border=ft.border.all(1, 'outline')))
            x += 100
        self.tableau = []
        x = 0
        for i in range(7):
            self.tableau.append(Slot(solitaire=self, top=150, left=x, border=None))
            x += 100
        self.controls.append(self.stock)
        self.controls.append(self.waste)
        self.controls.extend(self.foundations)
        self.controls.extend(self.tableau)
        self.update()

    def deal_cards(self):
        random.shuffle(self.cards)
        self.controls.extend(self.cards)
        first_slot = 0
        remaining_cards = self.cards
        while first_slot < len(self.tableau):
            for slot in self.tableau[first_slot:]:
                top_card = remaining_cards[0]
                top_card.place(slot)
                remaining_cards.remove(top_card)
            first_slot += 1
        for card in remaining_cards:
            card.place(self.stock)
            print(f'Card in stock: {card.rank.name} {card.suite.name}')
        self.update()
        for slot in self.tableau:
            slot.get_top_card().turn_face_up()
        self.update()

    def check_foundations_rules(self, card, slot):
        top_card = slot.get_top_card()
        if top_card is not None:
            return (card.suite.name == top_card.suite.name and card.rank.value - top_card.rank.value == 1)
        else:
            return card.rank.name == 'Ace'

    def check_tableau_rules(self, card, slot):
        top_card = slot.get_top_card()
        if top_card is not None:
            return (
                card.suite.color != top_card.suite.color
                and top_card.rank.value - card.rank.value == 1
                and top_card.face_up
            )
        else:
            return card.rank.name == 'King'

    def restart_stock(self):
        while len(self.waste.pile) > 0:
            card = self.waste.get_top_card()
            card.turn_face_down()
            card.move_on_top()
            card.place(self.stock)
        self.update

    def check_win(self):
        cards_num = 0
        for slot in self.foundations:
            cards_num += len(slot.pile)
        if cards_num == 52:
            return True
        return False

    def winning_sequence(self):
        for slot in self.foundations:
            for card in slot.pile:
                card.animate_position = 2000
                card.move_on_top()
                card.top = random.randint(0, SOLITAIRE_HEIGHT)
                card.left = random.randint(0, SOLITAIRE_WIDTH)
                self.update()
        self.controls.append(ft.AlertDialog(title=ft.Text('Congratulations! You won!'), open=True))


def main(page: ft.Page):
    page.on_error = lambda e: print('Page error:', e.data)
    solitaire = Solitaire()
    page.add(solitaire)


ft.app(target=main, assets_dir='assets')

```
</details>

トランプの画像はチュートリアルの[こちら](https://github.com/flet-dev/examples/tree/main/python/tutorials/solitaire/solitaire-final-part1/assets/images)を使用しています。


### 実行画面
![スクリーンショット 2024-02-16 17.11.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3591134/8820a56e-1587-af27-1a12-8f722cd4cb1e.png)
上記コードのみで画面・ゲーム処理全てを実装できました。


# 完成アプリをビルド
ここからは実際にビルドしていきます。


## 事前準備
Flutter SDK 3.16 以降をインストールし、flutter コマンドと dart コマンドの両方へのパスを PATH 環境変数に追加する必要があるそうです。
MacOSのアプリを作る場合は、追加でCocoaPodsが必要でした。

### ビルド用プロジェクトの作成
以下コマンドでプロジェクトを作成します。
```bash
flet create solitaire_app
```
以下のようにフォルダとファイルが生成されます。
![スクリーンショット 2024-02-16 15.12.01.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3591134/0b3f1b95-f216-f3a7-67bf-07bb72786ff4.png)

今回はお試しなので、assetsに画像を配置し、main.pyを直接先ほどのコードで書き換えました。

移動して実行できることを確認します。
```bash
cd solitaire_app
flet run
```

## Web
以下コマンドを実行します。
```bash
flet build web 
```
出力
![スクリーンショット 2024-02-16 15.48.44.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3591134/890fcb14-5388-93c0-01cc-7e73c8804108.png)

`./build/web`にファイルが出力されるので、これをPythonのhttp.server(組み込みモジュール)を使用し、Fletアプリをテストしてみます。

以下コマンドを実行します。
```bash
python -m http.server --directory build/web
```

ブラウザから`http://localhost:8000`にアクセスします。
![スクリーンショット 2024-02-16 16.59.36.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3591134/aedcd046-333a-97e4-7a8c-a2ef0de440c9.png)


## PCアプリ(Mac)
同じようにビルド(結構時間がかかりました。)
```bash
flet build macos
```
出力
![スクリーンショット 2024-02-16 22.41.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3591134/4678784d-7f3c-531b-f4b8-580f5433b9e7.png)

`./build/macos`に出力されており、Finderで見ると以下のように実行できるアプリケーションができています。
サイズが117.1MBと少し大きいです。
![スクリーンショット 2024-02-16 22.45.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3591134/2b760450-1cb2-9c04-2061-b69f33e3f58c.png)

起動すると以下のようになりました。(ナイトモードも反映されてました。)
![スクリーンショット 2024-02-16 22.49.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3591134/1864bcc8-26ba-a140-0358-fc0d242a5266.png)


### 他のOS
今回は試してはないですが、以下のコマンドを使用することでそれぞれのOS用にビルドできるみたいです。
- Linux デスクトップ アプリケーションを構築
`flet build linux`
- Windows デスクトップ アプリケーションを構築
`flet build windows`


## Android
ビルド
```bash
flet build apk
```
出力
![スクリーンショット 2024-02-17 0.02.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3591134/9750e5be-deb9-77b9-129d-1641042200c3.png)

`./build/apk`に出力されています。
これを実機で動かすと以下のようになりました。
![test.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3591134/11a2615e-46ec-9d79-bb31-365d72b56035.gif)
画面はサイズを固定してるので、右側が若干見切れていますが、動作に問題はなかったです。

### iOS
プロビジョニングプロファイルなどを用意する必要はありますが、iOSも同様に1コマンドでIPAファイルまで生成できるようです。

# 触ってみた感想
強みは簡単に多様なプラットフォームに対応できる点と画面のパーツが一通り揃っていて[ドキュメント](https://flet.dev/docs/controls/utility/)が豊富なところ
弱みは大規模アプリケーションの開発には制限があり、データバインディングの機構が無い=画面更新の処理も自力で書く必要があるので、コードが膨大になりそうです。

あとはCocoaPodsなど、ビルドする対象に合わせて別途パッケージなどが必要ですので、PC環境が汚れやすいです。
私の場合ですと、以前構築していたRubyやFlutterの環境が原因で、競合を起こしたりと環境を構築するのが結構大変でした。

小〜中規模のプロジェクトで、バックエンドにPythonを使いたい場合や、マルチプラットフォーム対応の柔軟性が欲しい場合には最適かと思います。
逆に、マルチプラットフォームを必要としない場合や、大規模なアプリケーション、特定の高度な要件には適していないと感じました。

発展途上ではありますが、頻繁にアップデートされていますので、今後も追っていきたいと思えるようなライブラリです。