Flutterを使って技術記事を聞けるようにしてみた

# はじめに

- 初Flutter、初Dartで挑戦します。
- エンジニア歴半年の技術ノート的な感じで見てください。
- サポーターズさん 2023 vol.9登壇作品となります。

ところで、私の愛すべき作品たちを見て貰えばわかると思いますが、プロダクト志向で全て出来ているのです。プロダクト志向、サービスの対価としてお金を貰う我々エンジニアにとって最も重要であり、今必要とされている力だとも思います。

改めて見返してみると、思い描くプロダクトを実装できるエンジニアってつよつよの方がとても多く、エンジニアとして完璧な方が多いと感じました。当たり前ですね。理想を語り、実現するならそれ相応の力を持っている必要性があるということです。

しかし、今の私には理想を語ることができても、エンジニアとして未熟すぎる。**基礎的なところからしっかり見直す必要があると考え、今回のハッカソンに挑みました。**

😤 **「エンジニア」として成長できるハッカソンにする。**


今回の目標地点となります。

# やること

- ios + webの多プラットフォームに展開できるアプリを作りたい。
- 技術記事（Qiita、Zennなど）の記事を取得し、音声で聞けるようにする。
- 使いやすいUIをFigmaでデザインし、Swiftでフロントに起こしたい。

- 要件から実装詳細までをしっかり定義し、READMEで管理
- issueごとにブランチを切って機能開発
- すぐ戻せるようにこまめにコミットする
- ドキュメントの作成
- コードを綺麗に書く（冗長になりすぎない）

- 技術記事でのアウトプット
- 話題のアジャイル開発

# 開発ルールをどう決めたか

- バグが発生した時に前のバージョンに戻せるようにする（最重要）
- mainには直pushはせず、本番環境と開発環境をしっかり分ける
- issueをtodoリストとして使用、何を行なっているか、実装するかを分かりやすくする
- 開発した内容はissueごとにPR → コードレビューして、ダブルチェックを行う
- **当たり前のことをしっかりやるルールを組みました**

# 実際の開発ルール↓

## DevListen README （最終更新 9/10 13:00）

# 開発ルール

開発を行う際は下記のブランチルールに従う

また、常に最新のdevelopeブランチの状態になるようmerge処理を行う

### ブランチ

- main: 現在デプロイされているプロダクト
- develope: 開発中バージョンの中心

開発を行う際は`develope`ブランチからブランチを派生して開発するものとする。

また、ブランチを作成する場合は下記の命名規則に従うものとする。

`develope/[IssueID]`

例: `develop/issue#0`

### Issueについて

作業する内容についてIssueを作成して作業するものとする。

### PRについて

各開発が終了した際はdevelopeブランチへPRを出すものとする。

また、PRを出した場合は各個人が自己レビューを行う。

PR申請者以外がレビューを行い問題ないと判断された場合はMerge処理を行う

### デブロイについて

Mainブランチが外部に公開する最新プロダクトとして更新された際にデブロイ作業を行うものとする.

---

この下に実装することをissue番号ごとに立て、何を実装するかを分かりやすくし、最終的に完成するもののブレがないように開発を行いました。（おそらくチーム開発で最重要）

Githubリンクは以下よりどうぞ

https://github.com/Kento210/DevListen

# 資料

📌 Canvaリンクから見ることができます。
https://www.canva.com/design/DAFuBKoZJkA/mARpVEeN9O7joWyk6W5Lvw/view?utm_content=DAFuBKoZJkA&utm_campaign=designshare&utm_medium=link&utm_source=publishsharelink

# 実装内容

![スクリーンショット 2023-09-10 14.28.33.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3504047/19422ef7-0000-6919-4539-04ec7a4334bb.png)


実装画面はこんな感じ。

Enter URLにURLを貼り付けると下のView部分に元記事が表示されます

- Fetch Speakボタン

記事の内容が音声で流れる

- Stop Speakingボタン

音声のストップボタン

- Reset Viewボタン

Viewのリセット用（更新ボタン的な感じ）

重要ワード：

記事から重要だと思われる単語を5個抽出し、教えてくれます。

読むのにかかる時間：

読むのにかかる時間を推定で教えてくれます。

![スクリーンショット 2023-09-10 14.38.46.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3504047/093d9688-13f7-2a53-a4ed-798624cf9ff3.png)


右上の設定機能から

Show Important Word スイッチ：

重要ワード機能のオンオフ

Show Reading Time スイッチ：

読むのにかかる時間表示機能のオンオフ

![スクリーンショット 2023-09-10 14.42.31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3504047/f79a29ac-2ce7-2f64-4435-214a49353155.png)


写真はShow Important Word スイッチをオフにした状態で、

私のQiitaの記事

https://qiita.com/Kento210/items/848533e12db3a12ddb87

を表示しています。

なかなかいいアプリなのでは…?

（満足度高い）

# 実装コード

Githubは以下からどうぞ！

https://github.com/Kento210/DevListen

`utilities.dart` :

非同期関数とユーティリティを格納

`my_home_page.dart` :

ウィジェットなど表示部分を格納

`main.dart` :

アプリケーションのエントリーポイント

utilities.dart

```dart
/*
  utilities.dart:
  非同期関数とユーティリティ関数を格納

  speak():
  テキストを音声に変換して読み上げる関数
  stopSpeaking():
  音声の読み上げを停止する関数
  fetchContent():
  指定されたURLからコンテンツを非同期に取得する関数

  extractImportantWords():
  与えられたテキストから重要な単語を抽出する関数
  calculateReadingTime():
  テキストの文字数から読むのにかかる時間（秒）を計算する関数
*/

import 'dart:math';
import 'package:http/http.dart' as http;
import 'package:flutter_tts/flutter_tts.dart';

// FlutterTtsのインスタンスを作成
FlutterTts flutterTts = FlutterTts();

// テキストを音声に変換して読み上げる関数
Future<void> speak(String text) async {
  await flutterTts.speak(text);
}

// 音声の読み上げを停止する関数
Future<void> stopSpeaking() async {
  await flutterTts.stop();
}

// 指定されたURLからコンテンツを非同期に取得する関数
Future<String> fetchContent(String url) async {
  final response = await http.get(Uri.parse(url));
  if (response.statusCode == 200) {
    return response.body;
  } else {
    throw Exception('Failed to load content');
  }
}

// 与えられたテキストから重要な単語を抽出する関数
List<String> extractImportantWords(String text) {
  // 非漢字・非ひらがな・非カタカナをスペースに置き換え
  String filteredText = text.replaceAll(RegExp(r'[^一-龯ぁ-んァ-ンー]'), ' ');
  // 単語に分割
  List<String> words = filteredText.split(' ').where((word) => word.isNotEmpty).toList();
  // 単語の出現頻度をカウント
  Map<String, int> frequency = {};

  for (String word in words) {
    if (frequency.containsKey(word)) {
      frequency[word] = frequency[word]! + 1;
    } else {
      frequency[word] = 1;
    }
  }

  // 出現頻度で単語をソート
  List<MapEntry<String, int>> sortedWords = frequency.entries.toList()
    ..sort((a, b) => b.value.compareTo(a.value));

  // 出現頻度の高い単語を返す
  return sortedWords.sublist(0, min(5, sortedWords.length)).map((entry) => entry.key).toList();
}

// 一分間に読める文字数（この数値は調整が必要）
const int charsPerMinute = 5000;

// テキストの文字数から読むのにかかる時間（秒）を計算する関数
String calculateReadingTime(String text) {
  int totalSeconds = (text.length / charsPerMinute * 60).ceil();
  int minutes = totalSeconds ~/ 60;
  int seconds = totalSeconds % 60;
  return '$minutes分$seconds秒';
}
```

my_home_page.dart

```dart
/*
  my_home_page.dart:
  ウィジェットを格納
*/

import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import 'package:html/parser.dart' show parse;
import '/utilities.dart';  // utilities.dartをインポートで、speakやstopSpeakingなどの関数を利用

// MyHomePageウィジェットの定義
class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

// MyHomePageの状態を管理するクラス
class _MyHomePageState extends State<MyHomePage> {
  String url = '';  // 入力されたURLを保存
  String content = '';  // フェッチしたコンテンツを保存
  List<String> importantWords = [];  // 重要な単語を保存
  WebViewController? _webViewController;  // WebViewのコントローラ
  bool showImportantWords = true;  // 重要な単語を表示するかどうかのフラグ
  bool showReadingTime = true;  // 読むのにかかる時間を表示するかどうかのフラグ

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.grey[300],  // Scaffoldの背景色を明るい灰色に
      appBar: AppBar(
        title: Text('DevListen'),
        backgroundColor: Colors.grey[800],  // AppBarの背景色を灰色に
        actions: [
          // 右上のポップアップメニュー
          PopupMenuButton(
            itemBuilder: (context) => [
              // 重要な単語の表示を切り替えるスイッチ
              PopupMenuItem(
                child: Row(
                  children: [
                    Text("Show Important Words"),
                    Switch(
                      value: showImportantWords,
                      onChanged: (value) {
                        setState(() {
                          showImportantWords = value;
                        });
                      },
                    ),
                  ],
                ),
              ),
              // 読むのにかかる時間の表示を切り替えるスイッチ
              PopupMenuItem(
                child: Row(
                  children: [
                    Text("Show Reading Time"),
                    Switch(
                      value: showReadingTime,
                      onChanged: (value) {
                        setState(() {
                          showReadingTime = value;
                        });
                      },
                    ),
                  ],
                ),
              ),
            ],
          ),
        ],
      ),
      body: Padding(
        padding: const EdgeInsets.all(16.0),
        child: Column(
          children: [
            // URLを入力するテキストフィールド
            TextField(
              decoration: InputDecoration(labelText: 'Enter URL'),
              onChanged: (value) {
                setState(() {
                  url = value;
                });
              },
            ),
            // 各種操作ボタン
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
              children: [
                // コンテンツをフェッチして読み上げるボタン
                Flexible(
                  child: ElevatedButton(
                    onPressed: () async {
                      content = await fetchContent(url);
                      var document = parse(content);
                      var plainText = document.body!.text;
                      importantWords = extractImportantWords(plainText);
                      speak(plainText);
                      setState(() {});
                    },
                    child: Text('Fetch Speak'),
                    style: ElevatedButton.styleFrom(
                      primary: Colors.grey[800],  // ボタンの背景色を灰色に
                      alignment: Alignment.center,
                    ),
                  ),
                ),
                // 読み上げを停止するボタン
                Flexible(
                  child: ElevatedButton(
                    onPressed: () {
                      stopSpeaking();
                    },
                    child: Text('Stop Speaking'),
                    style: ElevatedButton.styleFrom(
                      primary: Colors.grey[800],
                      alignment: Alignment.center,
                    ),
                  ),
                ),
                // WebViewをリセットするボタン
                Flexible(
                  child: ElevatedButton(
                    onPressed: () {
                      if (_webViewController != null) {
                        _webViewController!.loadUrl(url);
                      }
                    },
                    child: Text('Reset View'),
                    style: ElevatedButton.styleFrom(
                      primary: Colors.grey[800],
                      alignment: Alignment.center,
                    ),
                  ),
                ),
              ],
            ),
            // 重要な単語と読むのにかかる時間の表示エリア
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
              children: [
                if (showImportantWords)
                Container(
                  padding: EdgeInsets.all(8.0),
                  decoration: BoxDecoration(
                    border: Border.all(color: Colors.grey[800]!),
                  ),
                  child: Column(
                    children: [
                      Text(
                        '重要ワード:',
                        style: TextStyle(fontWeight: FontWeight.bold),
                      ),
                      Text(importantWords.join(', ')),
                    ],
                  ),
                ),
                if (showReadingTime)
                Container(
                  padding: EdgeInsets.all(8.0),
                  decoration: BoxDecoration(
                    border: Border.all(color: Colors.grey[800]!),
                  ),
                  child: Column(
                    children: [
                      Text(
                        '読むのにかかる時間（推定）:',
                        style: TextStyle(fontWeight: FontWeight.bold),
                      ),
                      Text(calculateReadingTime(content)),
                    ],
                  ),
                ),
              ],
            ),
            // WebViewの表示エリア
            Expanded(
              child: url.isNotEmpty
                  ? WebView(
                      initialUrl: url,
                      javascriptMode: JavascriptMode.unrestricted,
                      onWebViewCreated: (WebViewController webViewController) {
                        _webViewController = webViewController;
                      },
                    )
                  : Container(),
            ),
          ],
        ),
      ),
    );
  }
}
```

main.dart

```dart
/*
  main.dart:
  アプリケーションのエントリーポイント
*/

import 'package:flutter/material.dart';
import '/my_home_page.dart';  // MyHomePageウィジェットが定義されているmy_home_page.dartをインポート
import '/utilities.dart';  // ユーティリティ関数（speak, stopSpeakingなど）が定義されているutilities.dartをインポート

// アプリケーションのエントリーポイント
void main() {
  runApp(MyApp());
}

// MyApp クラスはアプリケーションのルートウィジェットを定義する
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // MaterialAppウィジェットでアプリケーションの基本的なビジュアル構造を提供
    return MaterialApp(
      home: MyHomePage(),  // MyHomePageウィジェットをホームページとして設定
    );
  }
}
```

# 今回書いた記事〜〜〜！！！！！

https://qiita.com/Kento210/items/848533e12db3a12ddb87

https://qiita.com/Kento210/items/2c9a967b1b9451b5cbcd

そして現在の記事です！

3記事も書いたのすごい！！！！！！！！！！！！！！！！！！！！！（初心者の備忘録ですが）

# 最後に

具体的な定義、設計を行う + 新技術を使用でエンジニアとして大きく成長でき、目標を達成できたと考えております。あと、やっぱり完走した時の達成感はすごい。

フルスタックはやはり辛いので次のハッカソンは友達と参加します！！！