Introduction.
Talking cat-shaped robot “Mia” that speaks various dialects is under development.
We recently released an original message function (Mia speaks original phrases created by the user or recorded voice).
https://mia-cat.com/notice/original-message-toc
However, there was a lack of consideration that when a user creates more than 20 phrases, the created phrases are not visible, so we would like to support infinite scrolling display of the phrase list.
Problems with the current implementation
The server side has retrieved 20 phrases each.
The server side gets 20 phrases per reload to support infinite scrolling.
// 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)
}
Front end: only a list of phrases is state-managed.
The application provides two phrase lists: “Your Phrases” and “Public Phrases”. Your phrases are literally phrases that you have created to make the Mia main unit speak, and public phrases are phrases that are displayed when you have turned on public for the phrases you have created.
All phrases published by others are also displayed, and if users like any of the published phrases, they can import them as their own.
Currently, UserPhraseNotifier and
PublicPhraseNotifier
inherit from StateNotifier<List
> and manage only a list of phrases as a state. However, when implementing infinite scrolling, it is necessary to manage additional state information such as “whether it is loading” or “whether there is more data” in addition to the list of phrases, so flutter needs to be modified.
In the current implementation, _hasMore
and _isLoading
are managed internally, but the StateNotifier
state itself is only a list of phrases (List
). Therefore, isLoading
and hasMore
are not accessible on the UI side, and errors occur where the UserPhraseState
class is used.
class UserPhraseNotifier extends StateNotifier<List> {
final ApiClient apiClient;
int _currentPage = 0;
bool _hasMore = true;
bool _isLoading = false;
UserPhraseNotifier(this.apiClient) : super([]);
bool get isLoading => _isLoading;
bool get hasMore => _hasMore;
// その他のメソッド...
}
Front-end (Flutter) modified files
On the front end, the following files were mainly modified
user_phrase_notifier.dart
public_phrase_notifier.dart
phrase_list_screen.dart
Extended state management (notifier.dart)
First, update user_phrase_notifier.dart and
public_phrase_notifier.dart
to manage the necessary states for infinite scrolling. Specifically, manage the following additional information
- isLoading: Whether data is being fetched or not.
- hasMore: Whether more data exists.
Update UserPhraseNotifier
- UserPhraseState class: Newly defined class to manage a list of phrases
(phrases)
, whetherthey are
loading(isLoading
), and whetherthere is more
data(hasMore
). - Change
StateNotifier
inheritance: Change fromStateNotifier<List<UserPhraseWithSchedule>>
toStateNotifier<UserPhraseState>
to manage more complex states. StateNotifier<UserPhraseWithSchedule>> to StateNotifier<UserPhraseState> to allow more complex state management. - Change the way state is updated: use
state.copyWith
to update state when adding, deleting, or updating phrases. This allows the UI to correctly reflect changes. - loadUserPhrases method: Retrieves phrases of the specified page and updates the status.
- loadMoreUserPhrases method: Method to load the next page.
- reset method: initializes the state.
// 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 phrases;
final bool isLoading;
final bool hasMore;
UserPhraseState({
required this.phrases,
required this.isLoading,
required this.hasMore,
});
UserPhraseState copyWith({
List? phrases,
bool? isLoading,
bool? hasMore,
}) {
return UserPhraseState(
phrases: phrases ?? this.phrases,
isLoading: isLoading ?? this.isLoading,
hasMore: hasMore ?? this.hasMore,
);
}
}
class UserPhraseNotifier extends StateNotifier {
final ApiClient apiClient;
int _currentPage = 0;
final int _pageSize = 20;
UserPhraseNotifier(this.apiClient)
: super(UserPhraseState(phrases: [], isLoading: false, hasMore: true));
Future 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 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(
(ref) => UserPhraseNotifier(ref.watch(apiClientProvider)),
);
Update PublicPhraseNotifier
- PublicPhraseState class: manages the list of public phrases, whether they are being read or not, and whether more data is available.
- loadPublicPhrases method: Retrieves the public phrases of the specified page and updates its status.
- loadMorePublicPhrases method: Method to load the next page.
- reset method: initializes the state.
// 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 publicPhrases;
final bool isLoading;
final bool hasMore;
PublicPhraseState({
required this.publicPhrases,
required this.isLoading,
required this.hasMore,
});
PublicPhraseState copyWith({
List? publicPhrases,
bool? isLoading,
bool? hasMore,
}) {
return PublicPhraseState(
publicPhrases: publicPhrases ?? this.publicPhrases,
isLoading: isLoading ?? this.isLoading,
hasMore: hasMore ?? this.hasMore,
);
}
}
class PublicPhraseNotifier extends StateNotifier {
final ApiClient apiClient;
int _currentPage = 0;
final int _pageSize = 20;
PublicPhraseNotifier(this.apiClient)
: super(PublicPhraseState(publicPhrases: [], isLoading: false, hasMore: true));
Future 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 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(
(ref) => PublicPhraseNotifier(ref.watch(apiClientProvider)),
);
Update UI ( phrase_list_screen.dart )
Next, update phrase_list_screen.dart
to load new data as it scrolls.
- Add a ScrollController: each time the user scrolls, check to see if they are near the bottom of the list.
- _onScroll method: monitors the scroll position and loads new data when approaching the bottom of the list.
- Update to ListView.builder: added a “loading indicator” at the end of the list to show while data is being loaded.
- Additional data loading:
call
loadMoreUserPhrases or
loadMorePublicPhrases
to get data for the next page.
// 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 {
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 _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 _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 dayMap = {
'Mon': '月',
'Tue': '火',
'Wed': '水',
'Thu': '木',
'Fri': '金',
'Sat': '土',
'Sun': '日'
};
final List 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 _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
_PhraseListPublicPhraseTabState();
}
class _PhraseListPublicPhraseTabState
extends ConsumerState {
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 _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()),
);
}
},
),
);
}
}
operation check
Home screen → Click on the Create Phrase button → On the phrase list screen, create at least 20 phrases, both your phrases and public phrases.
When scrolling through the phrases created, the loading indicator now runs and the next phrase is displayed.
The current UserPhraseNotifier
managed only a list of phrases as states and did not include additional state information ( isLoading
, hasMore
), which caused errors when trying to access these states on the UI side.
Extended state management, using a class (UserPhraseState
) that includes isLoading
and hasMore
in addition to the list of phrases, so that the UI can correctly determine if the list of phrases is empty, if data is loading, and if data exists. The UI can now correctly determine if the list of phrases is empty if data is being loaded, and if data exists, allowing for infinite scrolling.