はじめに
先日、オリジナルメッセージ機能(ユーザーが作成したオリジナルフレーズや録音した音声をミーアが話す)をリリースした。
https://mia-cat.com/notice/original-message-toc
ただ、ユーザーがフレーズを20個以上作成すると、作成したフレーズが見切れてしまうという考慮もれがあったので、フレーズリストの無限スクロール表示対応をしたいと思う。
現状の実装の問題点
サーバーサイドは20フレーズずつ取得ずみ
サーバーサイドは無限スクロール対応として、1リロードあたり20フレーズずつ取得している。
// mia/http/user_phrase_handler.go
func (uph *UserPhraseHandler) HandleGetUserPhrases(c echo.Context) error {
uid := c.Get("uid").(string)
user, err := uph.userService.GetUser(uid)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"message": "User not found"})
}
// 無限スクロール対応として、1リロードあたり20フレーズずつ取得。
page, err := strconv.Atoi(c.QueryParam("page"))
if err != nil {
page = 0
}
size, err := strconv.Atoi(c.QueryParam("size"))
if err != nil {
size = 20
}
phrases, err := uph.userPhraseService.GetUserPhrasesWithImported(user.ID, page, size)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"message": "Failed to get phrases"})
}
return c.JSON(http.StatusOK, phrases)
}
func (uph *UserPhraseHandler) HandleGetPublicPhrases(c echo.Context) error {
uid := c.Get("uid").(string)
user, err := uph.userService.GetUser(uid)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"message": "User not found"})
}
page, err := strconv.Atoi(c.QueryParam("page"))
if err != nil {
page = 0
}
size, err := strconv.Atoi(c.QueryParam("size"))
if err != nil {
size = 20
}
publicPhrases, err := uph.userPhraseService.GetPublicPhrases(user.ID, page, size)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"message": "Failed to get public phrases"})
}
return c.JSON(http.StatusOK, publicPhrases)
}
フロントエンド:フレーズのリストしか状態管理していない
アプリでは、フレーズ一覧として「あなたのフレーズ」と「公開フレーズ」の2つを用意している。あなたのフレーズは、文字通り自分がミーア本体に喋らせるように作成したフレーズで、公開フレーズは自分が作成したフレーズを公開ONにした場合に表示されるフレーズ。
他人が公開したフレーズも全て表示され、ユーザーは公開フレーズの中で気に入ったフレーズがあれば、自分のフレーズとして取り込むことができるというもの。
現在のUserPhraseNotifier
とPublicPhraseNotifier
はStateNotifier<List<UserPhraseWithSchedule>>
を継承しており、状態としてフレーズのリストのみを管理している。しかし、無限スクロールを実装する際には、フレーズのリストに加えて「読み込み中かどうか」や「さらにデータがあるかどうか」といった追加の状態情報も管理する必要があるので、flutterの方は修正が必要。
現実装では、_hasMore
や _isLoading
は内部的に管理されているが、StateNotifier
の状態自体はフレーズのリスト (List<UserPhraseWithSchedule>
) のみ。そのため、UI 側では isLoading
や hasMore
にアクセスできず、UserPhraseState
クラスを使用している箇所でエラーが発生している。
class UserPhraseNotifier extends StateNotifier<List<UserPhraseWithSchedule>> {
final ApiClient apiClient;
int _currentPage = 0;
bool _hasMore = true;
bool _isLoading = false;
UserPhraseNotifier(this.apiClient) : super([]);
bool get isLoading => _isLoading;
bool get hasMore => _hasMore;
// その他のメソッド...
}
フロントエンド(Flutter)の修正ファイル
フロントエンドでは、主に以下のファイルを修正。
user_phrase_notifier.dart
public_phrase_notifier.dart
phrase_list_screen.dart
状態管理の拡張(notifier.dart)
まず、user_phrase_notifier.dart
とpublic_phrase_notifier.dart
を更新して、無限スクロールに必要な状態を管理できるようにする。具体的には、以下の追加情報を管理
- isLoading: データをフェッチ中かどうか。
- hasMore: さらにデータが存在するかどうか。
UserPhraseNotifierの更新
- UserPhraseStateクラス: フレーズのリスト (
phrases
)、読み込み中かどうか (isLoading
)、さらにデータがあるかどうか (hasMore
) を一を管理するためのクラスを新しく定義。 StateNotifier
の継承先を変更:StateNotifier<List<UserPhraseWithSchedule>>
からStateNotifier<UserPhraseState>
に変更し、より複雑な状態を管理できるようにする。- 状態の更新方法の変更: フレーズの追加、削除、更新時に
state.copyWith
を使って状態を更新する。これにより、UI が変更を正しく反映できる。 - loadUserPhrasesメソッド: 指定したページのフレーズを取得し、状態を更新する。
- loadMoreUserPhrasesメソッド: 次のページを読み込むためのメソッド。
- resetメソッド: 状態を初期化。
// user_phrase_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:clocky_app/api/api_client.dart';
import 'package:clocky_app/api/user_phrase_with_schedule.dart';
class UserPhraseState {
final List<UserPhraseWithSchedule> phrases;
final bool isLoading;
final bool hasMore;
UserPhraseState({
required this.phrases,
required this.isLoading,
required this.hasMore,
});
UserPhraseState copyWith({
List<UserPhraseWithSchedule>? phrases,
bool? isLoading,
bool? hasMore,
}) {
return UserPhraseState(
phrases: phrases ?? this.phrases,
isLoading: isLoading ?? this.isLoading,
hasMore: hasMore ?? this.hasMore,
);
}
}
class UserPhraseNotifier extends StateNotifier<UserPhraseState> {
final ApiClient apiClient;
int _currentPage = 0;
final int _pageSize = 20;
UserPhraseNotifier(this.apiClient)
: super(UserPhraseState(phrases: [], isLoading: false, hasMore: true));
Future<void> loadUserPhrases({int page = 0}) async {
if (state.isLoading) return;
state = state.copyWith(isLoading: true);
try {
final phrases = await apiClient.getUserPhrases(page: page, size: _pageSize);
state = state.copyWith(
phrases: page == 0 ? phrases : [...state.phrases, ...phrases],
hasMore: phrases.length == _pageSize,
isLoading: false,
);
_currentPage = page;
} catch (e) {
if (page == 0) {
state = state.copyWith(phrases: []);
}
state = state.copyWith(isLoading: false);
// エラーハンドリングを追加する場合はここに
throw Exception('Failed to load user phrases: $e');
}
}
Future<void> loadMoreUserPhrases() async {
if (!state.hasMore || state.isLoading) return;
final nextPage = _currentPage + 1;
await loadUserPhrases(page: nextPage);
}
void reset() {
_currentPage = 0;
state = UserPhraseState(phrases: [], isLoading: false, hasMore: true);
}
// 他のメソッド(add, update, delete)も状態を更新するように修正する
// 例: フレーズを追加した後にstate.phrasesを更新する
}
final userPhraseProvider =
StateNotifierProvider<UserPhraseNotifier, UserPhraseState>(
(ref) => UserPhraseNotifier(ref.watch(apiClientProvider)),
);
PublicPhraseNotifierの更新
- PublicPhraseStateクラス: 公開フレーズのリスト、読み込み中かどうか、さらにデータがあるかどうかを管理。
- loadPublicPhrasesメソッド: 指定したページの公開フレーズを取得し、状態を更新する。
- loadMorePublicPhrasesメソッド: 次のページを読み込むためのメソッド。
- resetメソッド: 状態を初期化。
// public_phrase_notifier.dart
import 'package:clocky_app/api/api_client.dart';
import 'package:clocky_app/api/user_public_phrase.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class PublicPhraseState {
final List<UserPublicPhrase> publicPhrases;
final bool isLoading;
final bool hasMore;
PublicPhraseState({
required this.publicPhrases,
required this.isLoading,
required this.hasMore,
});
PublicPhraseState copyWith({
List<UserPublicPhrase>? publicPhrases,
bool? isLoading,
bool? hasMore,
}) {
return PublicPhraseState(
publicPhrases: publicPhrases ?? this.publicPhrases,
isLoading: isLoading ?? this.isLoading,
hasMore: hasMore ?? this.hasMore,
);
}
}
class PublicPhraseNotifier extends StateNotifier<PublicPhraseState> {
final ApiClient apiClient;
int _currentPage = 0;
final int _pageSize = 20;
PublicPhraseNotifier(this.apiClient)
: super(PublicPhraseState(publicPhrases: [], isLoading: false, hasMore: true));
Future<void> loadPublicPhrases({int page = 0}) async {
if (state.isLoading) return;
state = state.copyWith(isLoading: true);
try {
final publicPhrases = await apiClient.getPublicPhrases(page: page, size: _pageSize);
state = state.copyWith(
publicPhrases: page == 0 ? publicPhrases : [...state.publicPhrases, ...publicPhrases],
hasMore: publicPhrases.length == _pageSize,
isLoading: false,
);
_currentPage = page;
} catch (e) {
if (page == 0) {
state = state.copyWith(publicPhrases: []);
}
state = state.copyWith(isLoading: false);
throw Exception('Failed to load public phrases: $e');
}
}
Future<void> loadMorePublicPhrases() async {
if (!state.hasMore || state.isLoading) return;
final nextPage = _currentPage + 1;
await loadPublicPhrases(page: nextPage);
}
void reset() {
_currentPage = 0;
state = PublicPhraseState(publicPhrases: [], isLoading: false, hasMore: true);
}
// 他のメソッド(importなど)も状態を更新するように修正する
}
final publicPhraseProvider =
StateNotifierProvider<PublicPhraseNotifier, PublicPhraseState>(
(ref) => PublicPhraseNotifier(ref.watch(apiClientProvider)),
);
UI(phrase_list_screen.dart)の更新
次に、phrase_list_screen.dart
を更新して、スクロールに応じて新しいデータを読み込むようにする。
- ScrollControllerの追加: ユーザーがスクロールするたびに、リストの下部に近づいたかどうかをチェックする。
- _onScrollメソッド: スクロール位置を監視し、リストの下部に近づいたら新しいデータを読み込む。
- ListView.builderの更新: リストの最後に「ローディングインジケーター」を追加し、データが読み込まれている間に表示。
- データの追加読み込み:
loadMoreUserPhrases
やloadMorePublicPhrases
を呼び出して、次のページのデータを取得。
// phrase_list_screen.dart
import 'package:clocky_app/api/public_phrase_notifier.dart';
import 'package:clocky_app/api/user_notifier.dart';
import 'package:clocky_app/api/user_phrase_notifier.dart';
import 'package:clocky_app/api/user_phrase_with_schedule.dart';
import 'package:clocky_app/screens/home/add_phrase_screen.dart';
import 'package:clocky_app/services/audio_storage_service.dart';
import 'package:clocky_app/styles/colors.dart';
import 'package:clocky_app/widgets/buttons.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class PhraseListScreen extends StatelessWidget {
const PhraseListScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: const Text('フレーズ一覧'),
bottom: TabBar(
indicator: const BoxDecoration(
border: Border(
bottom: BorderSide(
color: AppColors.primaryColor,
width: 2.0,
),
),
),
tabs: [
SizedBox(
width: MediaQuery.of(context).size.width / 2,
child: const Tab(
child: Text(
'あなたのフレーズ',
style: TextStyle(
fontWeight: FontWeight.bold,
),
),
),
),
SizedBox(
width: MediaQuery.of(context).size.width / 2,
child: const Tab(
child: Text(
'公開フレーズ',
style: TextStyle(
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
body: const TabBarView(
children: [
PhraseListYourPhraseTab(),
PhraseListPublicPhraseTab(),
],
),
),
);
}
}
class PhraseListYourPhraseTab extends ConsumerStatefulWidget {
const PhraseListYourPhraseTab({Key? key}) : super(key: key);
@override
_PhraseListYourPhraseTabState createState() =>
_PhraseListYourPhraseTabState();
}
class _PhraseListYourPhraseTabState
extends ConsumerState<PhraseListYourPhraseTab> {
late ScrollController _scrollController;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
_scrollController.addListener(_onScroll);
// 初回データのロード
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(userPhraseProvider.notifier).loadUserPhrases();
});
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
// リストの下部に近づいたら追加データをロード
ref.read(userPhraseProvider.notifier).loadMoreUserPhrases();
}
}
Future<void> _deletePhrase(int phraseId) async {
try {
await ref.read(userPhraseProvider.notifier).deleteUserPhrase(phraseId);
// 削除後にリストを再ロード(必要に応じて)
// await ref.read(userPhraseProvider.notifier).loadUserPhrases();
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('フレーズの削除に失敗しました: $e')),
);
}
}
Future<void> _navigateToEditPhraseScreen(
UserPhraseWithSchedule phraseWithSchedule) async {
// フレーズに関連する録音ファイルパスを取得
String? recordedFilePath = await AudioStorageService.getRecordedFilePath(
phraseWithSchedule.phrase.id);
Navigator.push(
context,
MaterialPageRoute(
builder: (context) => AddPhraseScreen(
phraseWithSchedule: phraseWithSchedule,
initialRecordedFilePath: recordedFilePath,
),
),
).then((_) {
// 編集後にリストをリロード
ref.read(userPhraseProvider.notifier).reset();
ref.read(userPhraseProvider.notifier).loadUserPhrases();
});
}
final Map<String, String> dayMap = {
'Mon': '月',
'Tue': '火',
'Wed': '水',
'Thu': '木',
'Fri': '金',
'Sat': '土',
'Sun': '日'
};
final List<String> dayOrder = [
'Mon',
'Tue',
'Wed',
'Thu',
'Fri',
'Sat',
'Sun'
];
String translateDaysToJapanese(String days) {
final dayList = days.split(",");
dayList.sort((a, b) => dayOrder.indexOf(a).compareTo(dayOrder.indexOf(b)));
return dayList.map((day) => dayMap[day] ?? day).join(', ');
}
Future<void> _navigateToAddPhraseScreen() async {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const AddPhraseScreen()),
).then((_) {
// 追加後にリストをリロード
ref.read(userPhraseProvider.notifier).reset();
ref.read(userPhraseProvider.notifier).loadUserPhrases();
});
}
@override
Widget build(BuildContext context) {
final userPhraseState = ref.watch(userPhraseProvider);
final phrases = userPhraseState.phrases;
final isLoading = userPhraseState.isLoading;
final hasMore = userPhraseState.hasMore;
final currentUser = ref.watch(userProvider);
return Scaffold(
body: phrases.isEmpty && isLoading
? const Center(child: CircularProgressIndicator())
: phrases.isEmpty
? const Center(child: Text('まだフレーズがありません。'))
: ListView.builder(
controller: _scrollController,
itemCount: phrases.length + (hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index < phrases.length) {
final phraseWithSchedule = phrases[index];
final daysInJapanese =
phraseWithSchedule.schedule != null &&
phraseWithSchedule.schedule!.days != null
? translateDaysToJapanese(
phraseWithSchedule.schedule!.days!)
: null;
final isPrivate = phraseWithSchedule.phrase.isPrivate;
final isImportedAndPrivate =
phraseWithSchedule.phrase.userId != currentUser?.id &&
isPrivate;
Widget? subtitle;
if (phraseWithSchedule.schedule != null &&
phraseWithSchedule.schedule!.time != null &&
phraseWithSchedule.schedule!.days!.isNotEmpty) {
subtitle = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$daysInJapanese:${phraseWithSchedule.schedule!.time}',
style: const TextStyle(
color: AppColors.secondaryTextColor,
fontSize: 14.0,
),
),
if (isImportedAndPrivate)
const Text(
'このフレーズは非公開化されましたので使用できません',
style: TextStyle(
color: Colors.red,
fontSize: 14.0,
),
),
],
);
} else if (isImportedAndPrivate) {
subtitle = const Text(
'このフレーズは非公開化されましたので使用できません',
style: TextStyle(
color: Colors.red,
fontSize: 14.0,
),
);
}
return Column(
children: [
ListTile(
title: Text(
phraseWithSchedule.phrase.phrase,
style: const TextStyle(
color: AppColors.textColor,
),
),
subtitle: subtitle,
onTap: () {
_navigateToEditPhraseScreen(phraseWithSchedule);
},
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit),
onPressed: () {
_navigateToEditPhraseScreen(
phraseWithSchedule);
},
),
IconButton(
icon: const Icon(Icons.delete),
onPressed: () {
_deletePhrase(phraseWithSchedule.phrase.id);
},
),
],
),
),
const Divider(
color: AppColors.disabledColor,
thickness: 0.5,
height: 8,
),
],
);
} else {
// ローディングインジケーター
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16.0),
child: Center(child: CircularProgressIndicator()),
);
}
},
),
bottomNavigationBar: Padding(
padding: const EdgeInsets.fromLTRB(16.0, 16.0, 16.0, 48.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'※ミーアの頭をタッチした時には、スケジュール設定されていないフレーズがランダムに再生されます',
style: TextStyle(
fontSize: 12.0,
color: AppColors.secondaryTextColor,
),
),
const SizedBox(height: 8.0),
AppButton(
text: 'フレーズを追加',
onPressed: _navigateToAddPhraseScreen,
),
],
),
),
);
}
}
class PhraseListPublicPhraseTab extends ConsumerStatefulWidget {
const PhraseListPublicPhraseTab({Key? key}) : super(key: key);
@override
_PhraseListPublicPhraseTabState createState() =>
_PhraseListPublicPhraseTabState();
}
class _PhraseListPublicPhraseTabState
extends ConsumerState<PhraseListPublicPhraseTab> {
late ScrollController _scrollController;
@override
void initState() {
super.initState();
_scrollController = ScrollController();
_scrollController.addListener(_onScroll);
// 初回データのロード
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(publicPhraseProvider.notifier).loadPublicPhrases();
});
}
@override
void dispose() {
_scrollController.removeListener(_onScroll);
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
// リストの下部に近づいたら追加データをロード
ref.read(publicPhraseProvider.notifier).loadMorePublicPhrases();
}
}
Future<void> _importPublicPhrase(int phraseId) async {
try {
await ref.read(userPhraseProvider.notifier).importPublicPhrase(phraseId);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('フレーズを取り込みました')),
);
// リストを再ロード
ref.read(publicPhraseProvider.notifier).reset();
ref.read(publicPhraseProvider.notifier).loadPublicPhrases();
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('フレーズの取り込みに失敗しました: $e')),
);
}
}
@override
Widget build(BuildContext context) {
final publicPhraseState = ref.watch(publicPhraseProvider);
final publicPhrases = publicPhraseState.publicPhrases;
final isLoading = publicPhraseState.isLoading;
final hasMore = publicPhraseState.hasMore;
return Scaffold(
body: publicPhrases.isEmpty && isLoading
? const Center(child: CircularProgressIndicator())
: publicPhrases.isEmpty
? const Center(child: Text('公開フレーズがありません。'))
: ListView.builder(
controller: _scrollController,
itemCount: publicPhrases.length + (hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index < publicPhrases.length) {
final phrase = publicPhrases[index];
// isOwn を利用して、自分のフレーズかどうかを判定
final bool isOwn = phrase.isOwn;
// isImported を利用して、取り込んだフレーズかどうかを判定
final bool isImported = phrase.isImported;
return Column(
children: [
ListTile(
title: Text(phrase.phrase),
trailing: isOwn
? null // 自分のフレーズなら「取り込む」ボタンを表示しない
: ElevatedButton(
style: ElevatedButton.styleFrom(
foregroundColor: Colors.white,
backgroundColor: isImported
? AppColors.disabledColor
: AppColors.primaryColor,
disabledForegroundColor: Colors.grey,
fixedSize: const Size(130, 40),
),
onPressed: isImported
? null // 取り込み済みの場合は無効化
: () {
_importPublicPhrase(phrase.id);
},
child: Text(
isImported ? '取り込み済み' : '取り込む',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 13.0,
color: Colors.white,
),
),
),
),
const Divider(
color: AppColors.disabledColor,
thickness: 0.5,
height: 8,
),
],
);
} else {
// ローディングインジケーター
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16.0),
child: Center(child: CircularProgressIndicator()),
);
}
},
),
);
}
}
動作確認
ホーム画面→フレーズを作成するボタンをクリック→フレーズ一覧画面で、あなたのフレーズ、公開フレーズともに、20件以上のフレーズを作成。
作成したフレーズをスクロールすると、ローディングインジケーターが走り、次のフレーズが表示されるようになった。
現在の UserPhraseNotifier
では、状態としてフレーズのリストのみを管理しており、追加の状態情報(isLoading
、hasMore
)が含まれていなかったため、UI 側でこれらの状態にアクセスしようとするとエラーが発生していた。
状態管理を拡張し、フレーズのリストに加えて isLoading
や hasMore
を含むクラス (UserPhraseState
) を使用することで、UI 側でフレーズのリストが空かどうか、データがロード中かどうか、さらにデータが存在するかどうかを正しく判断できるようになり、無限スクロールできるようになった。