Repository: HACK-U-2025-2/HACKU
Pull request #119: feat: メモ一覧ページの見た目実装
Author: login: Rozelin-dc
Created: 2025-05-19T14:30:28Z | Merged: 2025-05-22T08:39:30Z

close #66 
relate #21 

## 内容
- メモ一覧ページの見た目を実装しました

![image](https://github.com/user-attachments/assets/3f4f5836-5834-45f7-8f96-96d2fc2310c5)

## 備考
- 検索処理は #75 で実装するのでここでは実装していません
- メモの追加導線も #67 で実装予定なので、ここでは実装していません

Linked issue #66: sprint1: 検索欄とメモの表示

Diff:
diff --git a/frontend/lib/pages/memo_list_view_page.dart b/frontend/lib/pages/memo_list_view_page.dart
index 355eaaa..a938b23 100644
--- a/frontend/lib/pages/memo_list_view_page.dart
+++ b/frontend/lib/pages/memo_list_view_page.dart
@@ -1,14 +1,27 @@
 import 'package:flutter/material.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:frontend/models/memo.dart';
 import 'package:frontend/models/memo_preview.dart';
+import 'package:frontend/models/tag.dart';
 import 'package:frontend/widgets/destination_navigation_drawer.dart';
 import 'package:frontend/widgets/memo_card.dart';
 
-class MemoListViewPage extends StatelessWidget {
+class MemoListViewPage extends HookWidget {
   const MemoListViewPage({super.key});
 
   @override
   Widget build(BuildContext context) {
+    final searchText = useState('');
+    final debouncedSearchText = useDebounced(
+      searchText.value,
+      const Duration(milliseconds: 500),
+    );
+    useEffect(() {
+      // TODO(Rozelin-dc): 検索処理, https://github.com/HACK-U-2025-2/HACKU/issues/75
+      debugPrint('Search text: $debouncedSearchText');
+      return null;
+    }, [debouncedSearchText]);
+
     final mockMemoList = List.generate(
       20,
       (index) => MemoPreview(
@@ -18,17 +31,96 @@ class MemoListViewPage extends StatelessWidget {
         createdAt: DateTime.now(),
       ),
     );
+    final mockTagList = List.generate(
+      20,
+      (index) => Tag(id: TagId(index), name: 'タグ$index'),
+    );
 
     return Scaffold(
       drawer: const DestinationNavigationDrawer(),
       appBar: AppBar(title: const Text('メモ一覧')),
-      body: ListView(
-        children: [
-          ...mockMemoList.map(
-            (memoPreview) => MemoCard(memoPreview: memoPreview),
+      body: SafeArea(
+        child: Padding(
+          padding: const EdgeInsets.only(top: 20, left: 20, right: 20),
+          child: Column(
+            spacing: 20,
+            children: [
+              _TagsHorizontalListView(tags: mockTagList),
+              SearchBar(
+                leading: const Icon(Icons.search),
+                hintText: '検索キーワードを入力',
+                onChanged: (value) {
+                  searchText.value = value;
+                },
+              ),
+              Expanded(
+                child: Scrollbar(
+                  child: ListView.separated(
+                    padding: const EdgeInsets.only(bottom: 20),
+                    separatorBuilder:
+                        (context, index) => const SizedBox(height: 20),
+                    itemCount: mockMemoList.length,
+                    itemBuilder:
+                        (context, index) =>
+                            MemoCard(memoPreview: mockMemoList[index]),
+                  ),
+                ),
+              ),
+            ],
           ),
-        ],
+        ),
+      ),
+    );
+  }
+}
+
+class _TagsHorizontalListView extends StatelessWidget {
+  const _TagsHorizontalListView({required this.tags});
+
+  final List<Tag> tags;
+
+  @override
+  Widget build(BuildContext context) {
+    return SizedBox(
+      height: 50,
+      child: ListView.separated(
+        padding: const EdgeInsets.symmetric(vertical: 8),
+        scrollDirection: Axis.horizontal,
+        itemCount: tags.length,
+        separatorBuilder: (context, index) => const SizedBox(width: 8),
+        itemBuilder: (context, index) {
+          final tag = tags[index];
+          return _TagChip(tag: tag);
+        },
       ),
     );
   }
 }
+
+class _TagChip extends HookWidget {
+  const _TagChip({required this.tag});
+
+  final Tag tag;
+
+  @override
+  Widget build(BuildContext context) {
+    final isSelected = useState(false);
+
+    return FilterChip(
+      label: Text(tag.name),
+      selected: isSelected.value,
+      showCheckmark: false,
+      onSelected: (value) {
+        // TODO(Rozelin-dc): タグタップによる検索の実装, https://github.com/HACK-U-2025-2/HACKU/issues/75
+        isSelected.value = true;
+      },
+      onDeleted:
+          // 選択されていない時に削除ボタンが出ないように
+          isSelected.value
+              ? () {
+                isSelected.value = false;
+              }
+              : null,
+    );
+  }
+}
diff --git a/frontend/lib/theme.dart b/frontend/lib/theme.dart
index b9db20a..0a100c3 100644
--- a/frontend/lib/theme.dart
+++ b/frontend/lib/theme.dart
@@ -16,6 +16,9 @@ ThemeData buildThemeData(Brightness brightness) {
     appBarTheme: const AppBarTheme(centerTitle: true),
     tabBarTheme: const TabBarTheme(indicatorSize: TabBarIndicatorSize.tab),
     chipTheme: ChipThemeData(backgroundColor: backgroundColor),
+    searchBarTheme: const SearchBarThemeData(
+      elevation: WidgetStatePropertyAll(0),
+    ),
     floatingActionButtonTheme: FloatingActionButtonThemeData(
       shape: const CircleBorder(),
       backgroundColor: colorScheme.primary,


Reviews:
user: login: tyPhoon-collab
body: 実装ありがとうございます！良いと思います👍️
`useDebounced`は知らなかったので勉強になりました🙇‍♂️

一点だけ細かい部分を指摘させていただきましたので、確認お願いします🙏
state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-05-19T21:15:14Z
user: login: Mattya3
body: ユーザーの操作対象が動くのはUI的にまずかった気がします．(直近で忠告をしていたサイトを見た気がするのですが，どこだったか忘れてしまいました．)

<img width="321" alt="スクリーンショット 2025-05-20 20 30 56" src="https://github.com/user-attachments/assets/a8931478-9b77-485c-9528-0fe9b8cdc81d" />

<img width="321" alt="スクリーンショット 2025-05-20 20 30 51" src="https://github.com/user-attachments/assets/98b654af-5b0c-4ec3-ab8a-896ba6c6122e" />


あと可能ならですが，どこまでスクロールしたかがわかるバー(名称がわからずすみません)があるとタグ・メモ両方スクロールしやすいかと思います．

state: CHANGES_REQUESTED
author_association: COLLABORATOR
submitted_at: 2025-05-20T11:38:58Z
user: login: Rozelin-dc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-20T11:55:59Z
user: login: tyPhoon-collab
body: 実装ありがとうございます！良いと思います👍️
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-21T01:42:03Z
user: login: Mattya3
body: こちらでも確認できました．
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-22T07:35:41Z

Review comments:
diff_hunk: @@ -18,17 +31,93 @@ class MemoListViewPage extends StatelessWidget {
         createdAt: DateTime.now(),
       ),
     );
+    final mockTagList = List.generate(
+      20,
+      (index) => Tag(id: TagId(index), name: 'タグ$index'),
+    );
 
     return Scaffold(
       drawer: const DestinationNavigationDrawer(),
       appBar: AppBar(title: const Text('メモ一覧')),
-      body: ListView(
-        children: [
-          ...mockMemoList.map(
-            (memoPreview) => MemoCard(memoPreview: memoPreview),
+      body: SafeArea(
+        child: Padding(
+          padding: const EdgeInsets.all(20),
+          child: Column(
+            spacing: 20,
+            children: [
+              _TagsHorizontalListView(tags: mockTagList),
+              SearchBar(
+                leading: const Icon(Icons.search),
+                hintText: '検索キーワードを入力',
+                onChanged: (value) {
+                  searchText.value = value;
+                },
+              ),
+              Expanded(
+                child: ListView.separated(
+                  separatorBuilder:
+                      (context, index) => const SizedBox(height: 20),
+                  itemCount: mockMemoList.length,
+                  itemBuilder:
+                      (context, index) =>
+                          MemoCard(memoPreview: mockMemoList[index]),
+                ),
+              ),
+            ],
           ),
-        ],
+        ),
+      ),
+    );
+  }
+}
+
+class _TagsHorizontalListView extends StatelessWidget {
path: frontend/lib/pages/memo_list_view_page.dart
user: login: tyPhoon-collab
body: このクラスの共通化はしても良さそうですね、後々やりましょう👍️
本PRでは対応不要だと思います

itemBuilderだけ委譲すれば良さそうです
created_at: 2025-05-19T20:49:46Z
author_association: COLLABORATOR
diff_hunk: @@ -18,17 +31,93 @@ class MemoListViewPage extends StatelessWidget {
         createdAt: DateTime.now(),
       ),
     );
+    final mockTagList = List.generate(
+      20,
+      (index) => Tag(id: TagId(index), name: 'タグ$index'),
+    );
 
     return Scaffold(
       drawer: const DestinationNavigationDrawer(),
       appBar: AppBar(title: const Text('メモ一覧')),
-      body: ListView(
-        children: [
-          ...mockMemoList.map(
-            (memoPreview) => MemoCard(memoPreview: memoPreview),
+      body: SafeArea(
+        child: Padding(
+          padding: const EdgeInsets.all(20),
+          child: Column(
+            spacing: 20,
+            children: [
+              _TagsHorizontalListView(tags: mockTagList),
+              SearchBar(
+                leading: const Icon(Icons.search),
+                hintText: '検索キーワードを入力',
+                onChanged: (value) {
+                  searchText.value = value;
+                },
+              ),
+              Expanded(
+                child: ListView.separated(
+                  separatorBuilder:
+                      (context, index) => const SizedBox(height: 20),
+                  itemCount: mockMemoList.length,
+                  itemBuilder:
+                      (context, index) =>
+                          MemoCard(memoPreview: mockMemoList[index]),
+                ),
+              ),
+            ],
path: frontend/lib/pages/memo_list_view_page.dart
user: login: tyPhoon-collab
body: Paddingで囲ってしまうとListViewの下部の（SafeAreaを除く）表示できるスペースも余白になってしまいます。画面下部の余白に関しては、ListViewのpaddingを用いるとより良いかもしれません

```dart
// 最小限の変更でやるとしたら、、、
        Padding(
          padding: const EdgeInsets.only(top: 20, left: 20, right: 20), // 変更点
          child: Column(
            spacing: 20,
            children: [
              _TagsHorizontalListView(tags: mockTagList),
              SearchBar(
                leading: const Icon(Icons.search),
                hintText: '検索キーワードを入力',
                onChanged: (value) {
                  searchText.value = value;
                },
              ),
              Expanded(
                child: ListView.separated(
                  padding: const EdgeInsets.only(bottom: 20), // 変更点
                  separatorBuilder:
                      (context, index) => const SizedBox(height: 20),
                  itemCount: mockMemoList.length,
                  itemBuilder:
                      (context, index) =>
                          MemoCard(memoPreview: mockMemoList[index]),
                ),
              ),
            ],
          ),
        ),
```
## 比較
下部に注目すると、まだコンテンツが有るときは余白なし、コンテンツの終端は余白を入れることができる。

### 改善前
<img width="411" alt="image" src="https://github.com/user-attachments/assets/ee4fdab7-c168-419c-b97f-d7b781b71045" />
<img width="411" alt="image" src="https://github.com/user-attachments/assets/3266e6ed-9767-428a-a881-91b985a88581" />


### 改善後
<img width="411" alt="image" src="https://github.com/user-attachments/assets/c0ee1310-42d0-4701-904b-1e8457b7decf" />
<img width="411" alt="image" src="https://github.com/user-attachments/assets/670a835d-356b-46d3-beba-dcf8021c39d1" />

created_at: 2025-05-19T21:11:28Z
author_association: COLLABORATOR
diff_hunk: @@ -18,17 +31,93 @@ class MemoListViewPage extends StatelessWidget {
         createdAt: DateTime.now(),
       ),
     );
+    final mockTagList = List.generate(
+      20,
+      (index) => Tag(id: TagId(index), name: 'タグ$index'),
+    );
 
     return Scaffold(
       drawer: const DestinationNavigationDrawer(),
       appBar: AppBar(title: const Text('メモ一覧')),
-      body: ListView(
-        children: [
-          ...mockMemoList.map(
-            (memoPreview) => MemoCard(memoPreview: memoPreview),
+      body: SafeArea(
+        child: Padding(
+          padding: const EdgeInsets.all(20),
+          child: Column(
+            spacing: 20,
+            children: [
+              _TagsHorizontalListView(tags: mockTagList),
+              SearchBar(
+                leading: const Icon(Icons.search),
+                hintText: '検索キーワードを入力',
+                onChanged: (value) {
+                  searchText.value = value;
+                },
+              ),
+              Expanded(
+                child: ListView.separated(
+                  separatorBuilder:
+                      (context, index) => const SizedBox(height: 20),
+                  itemCount: mockMemoList.length,
+                  itemBuilder:
+                      (context, index) =>
+                          MemoCard(memoPreview: mockMemoList[index]),
+                ),
+              ),
+            ],
path: frontend/lib/pages/memo_list_view_page.dart
user: login: Rozelin-dc
body: cssとは勝手が違うんですね。指摘ありがとうございます。修正しました
created_at: 2025-05-20T11:55:59Z
author_association: CONTRIBUTOR

PR comments:
user: login: Rozelin-dc
created_at: 2025-05-20T11:57:10Z
body: スクロールバーについてはスクロールしている間は表示されていると思います

> ユーザーの操作対象が動く

こちらについては意図が測りかねているのでもう少し詳しくお願いできますか？
author_association: CONTRIBUTOR
user: login: Mattya3
created_at: 2025-05-21T06:30:33Z
body: > スクロールバーについてはスクロールしている間は表示されていると思います

自分の環境だとandroid, ios両方で表示されていないです...

chrome上では見えますが，スマホとは仕様が異なっていそうです
author_association: COLLABORATOR
user: login: tyPhoon-collab
created_at: 2025-05-21T08:05:37Z
body: > > スクロールバーについてはスクロールしている間は表示されていると思います
> 
> 自分の環境だとandroid, ios両方で表示されていないです...
> 
> chrome上では見えますが，スマホとは仕様が異なっていそうです


私の環境でもスマホでは表示されません。ただ、スクロールバーを表示しないアプリも多いと思います。以下はGoogleの例です

<img src="https://github.com/user-attachments/assets/8c5111ea-af23-41b6-906c-75c997118447" width=256px>
<img src="https://github.com/user-attachments/assets/9d6088ea-a746-4b4c-8fb2-5b3baf4abc29" width=256px>


**中央部の天気が表示されている部分**が今回と似たケースだと思いますが、スクロールバーは表示されないです

個人的にはスクロールバーは無くても良いかなとは思います
author_association: COLLABORATOR
user: login: Mattya3
created_at: 2025-05-21T08:13:10Z
body: タグはいいのですが，メモの方は量が多くなるのでスクロール速度を上げるために欲しい気もするんですよね．
@Rozelin-dc @marie1a22 
スクロールバーについてはどちらがいいでしょうか．
author_association: COLLABORATOR
user: login: tyPhoon-collab
created_at: 2025-05-21T08:44:13Z
body: なるほど、メモの方でしたか、勘違いしていました🙇‍♂️

縦スクロールの場合は確かにあっても良さそうですね、、、有名どころだとXはスクロールバーがありました。ただ、Googleアプリのほうはなかったです。アプリごとでまちまちで、確かにコンテンツの量で有無を決めたほうが良さそうです

実装の方はListViewをScrollbarというWidgetでラップするだけなので簡単そうです
[Scrollbar class - material library - Dart API](https://api.flutter.dev/flutter/material/Scrollbar-class.html)


author_association: COLLABORATOR
user: login: Rozelin-dc
created_at: 2025-05-22T02:16:46Z
body: スマホ環境の動作確認ができてないですが、これでスクロールバー表示されてますか？
author_association: CONTRIBUTOR
user: login: tyPhoon-collab
created_at: 2025-05-22T07:29:24Z
body: 表示されています👍️
<img src="https://github.com/user-attachments/assets/2d6e1194-9844-47c5-a102-48bf8a1045d5" width=256px>

author_association: COLLABORATOR