ログインをメアドに変更

This commit is contained in:
2024-07-15 06:54:02 +09:00
parent ae05a8bbcd
commit 616f87c0c5
11 changed files with 140 additions and 102 deletions

View File

@ -20,6 +20,7 @@ import 'package:rogapp/pages/index/index_controller.dart';
import 'package:rogapp/routes/app_pages.dart';
import 'package:rogapp/utils/location_controller.dart';
import 'package:rogapp/utils/string_values.dart';
import 'package:rogapp/widgets/debug_widget.dart';
import 'package:shared_preferences/shared_preferences.dart';
// import 'package:is_lock_screen/is_lock_screen.dart';
@ -127,7 +128,7 @@ void main() async {
FlutterError.presentError(details);
Get.log('Flutter error: ${details.exception}');
Get.log('Stack trace: ${details.stack}');
ErrorService.reportError(details.exception, details.stack ?? StackTrace.current, deviceInfo);
ErrorService.reportError(details.exception, details.stack ?? StackTrace.current, deviceInfo, LogManager().operationLogs);
};
//Get.put(LocationController());
@ -153,7 +154,7 @@ void main() async {
//runApp(const MyApp());
}
/*
Future<void> requestLocationPermission() async {
try {
final status = await Permission.locationAlways.request();
@ -167,7 +168,7 @@ Future<void> requestLocationPermission() async {
print('Error requesting location permission: $e');
}
}
*/
// メモリ使用量の解説https://qiita.com/hukusuke1007/items/e4e987836412e9bc73b9
@ -366,6 +367,11 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
}
WidgetsBinding.instance.addObserver(this);
// Add to clear
WidgetsBinding.instance.addPostFrameCallback((_) {
showLocationDisclosure(context);
});
/*
WidgetsBinding.instance.addPostFrameCallback((_) async {
await PermissionController.checkAndRequestPermissions();
@ -375,6 +381,45 @@ class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
debugPrint("Start MyAppState...");
}
void showLocationDisclosure(BuildContext context) {
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: Text('位置情報の使用について'),
content: const SingleChildScrollView(
child: ListBody(
children: <Widget>[
Text('このアプリでは、以下の目的で位置情報を使用します:'),
Text('• チェックポイントの自動チェックイン(アプリが閉じているときも含む)'),
Text('• 移動履歴の記録(バックグラウンドでも継続)'),
Text('• 現在地周辺の情報表示'),
Text('\nバックグラウンドでも位置情報を継続的に取得します。'),
Text('これにより、バッテリーの消費が増加する可能性があります。'),
],
),
),
actions: <Widget>[
TextButton(
child: const Text('同意しない'),
onPressed: () {
Navigator.of(context).pop();
// アプリを終了するなどの処理
},
),
TextButton(
child: const Text('同意する'),
onPressed: () {
Navigator.of(context).pop();
requestLocationPermission();
},
),
],
);
},
);
}
@override
void dispose() {

View File

@ -1,10 +1,13 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:rogapp/pages/index/index_controller.dart';
import 'package:rogapp/widgets/debug_widget.dart';
class ChangePasswordPage extends StatelessWidget {
ChangePasswordPage({Key? key}) : super(key: key);
LogManager logManager = LogManager();
IndexController indexController = Get.find<IndexController>();
TextEditingController oldPasswordController = TextEditingController();
@ -20,6 +23,7 @@ class ChangePasswordPage extends StatelessWidget {
backgroundColor: Colors.white,
leading: IconButton(
onPressed: () {
logManager.addOperationLog('User clicked cancel button on the drawer');
Navigator.pop(context);
},
icon: const Icon(
@ -89,6 +93,7 @@ class ChangePasswordPage extends StatelessWidget {
.text.isEmpty ||
newPasswordController
.text.isEmpty) {
logManager.addOperationLog('User tried to login with blank old password ${oldPasswordController.text} or new password ${newPasswordController.text}.');
Get.snackbar(
"no_values".tr,
"values_required".tr,

View File

@ -5,6 +5,7 @@ import 'package:rogapp/pages/index/index_controller.dart';
import 'package:rogapp/routes/app_pages.dart';
import 'package:rogapp/services/auth_service.dart';
import 'package:rogapp/utils/database_helper.dart';
import 'package:rogapp/widgets/debug_widget.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:rogapp/pages/WebView/WebView_page.dart';
@ -16,6 +17,8 @@ class DrawerPage extends StatelessWidget {
final IndexController indexController = Get.find<IndexController>();
LogManager logManager = LogManager();
// 要検討URLの起動に失敗した場合のエラーハンドリングが不十分です。適切なエラーメッセージを表示するなどの処理を追加してください。
//
/*
@ -26,6 +29,7 @@ class DrawerPage extends StatelessWidget {
void _launchURL(BuildContext context,String urlString) async {
try {
logManager.addOperationLog('User clicked ${urlString} on the drawer');
Uri url = Uri.parse(urlString);
if (await canLaunchUrl(url)) {
await launchUrl(url);
@ -52,14 +56,8 @@ class DrawerPage extends StatelessWidget {
Widget build(BuildContext context) {
return SafeArea(
child: Drawer(
// Add a ListView to the drawer. This ensures the user can scroll
// through the options in the drawer if there isn't enough vertical
// space to fit everything.
child: Column(
children: [
// 最初のアイテムは、ユーザーのログイン状態に応じて表示が変わります。
// ユーザーがログインしていない場合は、"drawer_title".trというテキストを表示します。
// ユーザーがログインしている場合は、ユーザーのメールアドレスを表示します。
Container(
height: 100,
color: Colors.amber,
@ -81,9 +79,6 @@ class DrawerPage extends StatelessWidget {
),
)),
),
// 次に、IndexControllerのcurrentUserリストが空かどうかに応じて、ログインまたはログアウトのアイテムを表示します。
// currentUserリストが空の場合は、"login".trというテキストのログインアイテムを表示し、タップするとAppPages.LOGINにナビゲートします。
// currentUserリストが空でない場合は、"logout".trというテキストのログアウトアイテムを表示し、タップするとindexController.logout()を呼び出してログアウトし、AppPages.LOGINにナビゲートします。
Obx(() => indexController.currentUser.isEmpty
? ListTile(
leading: const Icon(Icons.login),
@ -100,8 +95,6 @@ class DrawerPage extends StatelessWidget {
Get.toNamed(AppPages.LOGIN);
},
)),
// パスワード変更のアイテムは、ユーザーがログインしている場合にのみ表示されます。
// "change_password".trというテキストを表示し、タップするとAppPages.CHANGE_PASSWORDにナビゲートします。
indexController.currentUser.isNotEmpty
? ListTile(
leading: const Icon(Icons.password),
@ -114,8 +107,6 @@ class DrawerPage extends StatelessWidget {
width: 0,
height: 0,
),
// サインアップのアイテムは、ユーザーがログインしていない場合にのみ表示されます。
// "sign_up".trというテキストを表示し、タップするとAppPages.REGISTERにナビゲートします。
indexController.currentUser.isEmpty
? ListTile(
leading: const Icon(Icons.person),
@ -128,20 +119,19 @@ class DrawerPage extends StatelessWidget {
width: 0,
height: 0,
),
// リセットのアイテムは、ユーザーがログインしている場合にのみ表示されます。
// タップすると、確認ダイアログを表示し、ユーザーがリセットを確認するとDestinationControllerのresetRogaining()メソッドを呼び出してゲームデータをリセットします。
indexController.currentUser.isNotEmpty
? ListTile(
leading: const Icon(Icons.password),
title: const Text("リセット"),
title: Text('reset_button'.tr),
onTap: () {
logManager.addOperationLog('User clicked RESET button on the drawer');
// 要検討:リセット操作の確認メッセージをローカライズすることを検討してください。
//
Get.defaultDialog(
title: "リセットしますがよろしいですか?",
middleText: "これにより、すべてのゲーム データが削除され、すべての状態が削除されます",
textConfirm: "確認する",
textCancel: "キャンセルする",
title: "reset_title".tr,
middleText: "reset_message".tr,
textConfirm: "confirm".tr,
textCancel: "cancel".tr,
onCancel: () => Get.back(),
onConfirm: () async {
DestinationController destinationController =
@ -157,8 +147,8 @@ class DrawerPage extends StatelessWidget {
//destinationController.deleteDBDestinations();
Get.back();
Get.snackbar(
"リセット完了",
"すべてリセットされました。ロゲ開始から再開して下さい。",
"reset_done".tr,
"reset_explain".tr,
backgroundColor: Colors.green,
colorText: Colors.white,
duration: const Duration(seconds: 3),
@ -171,20 +161,19 @@ class DrawerPage extends StatelessWidget {
width: 0,
height: 0,
),
// アカウント削除のアイテムは、ユーザーがログインしている場合にのみ表示されます。
// "delete_account".trというテキストを表示し、タップするとAuthService.deleteUser()を呼び出してアカウントを削除し、AppPages.TRAVELにナビゲートします。
indexController.currentUser.isNotEmpty
? ListTile(
leading: const Icon(Icons.delete_forever),
title: Text("delete_account".tr),
onTap: () {
Get.defaultDialog(
title: "アカウントを削除しますがよろしいですか?",
middleText: "これにより、アカウント情報とすべてのゲーム データが削除され、すべての状態が削除されます",
textConfirm: "確認する",
textCancel: "キャンセルする",
title: "delete_account_title".tr,
middleText: "delete_account_middle".tr,
textConfirm: "confirm".tr,
textCancel: "cancel".tr,
onCancel: () => Get.back(),
onConfirm: () {
logManager.addOperationLog('User clicked Confirm button on the account delete dialog');
String token = indexController.currentUser[0]['token'];
AuthService.deleteUser(token).then((value) {
if (value.isNotEmpty) {
@ -205,53 +194,6 @@ class DrawerPage extends StatelessWidget {
width: 0,
height: 0,
),
/*
// ユーザーデータ削除のアイテムは、ユーザーがログインしている場合にのみ表示されます。
// タップすると、AuthService.deleteUser()を呼び出してユーザーデータを削除します。
indexController.currentUser.isNotEmpty
? ListTile(
// 要検討:アカウント削除のリクエストが失敗した場合のエラーハンドリングを追加することをお勧めします。
//
leading: const Icon(Icons.delete_forever),
title: Text("ユーザーデータを削除する".tr),
onTap: () {
Get.defaultDialog(
title: "アカウントを削除しますがよろしいですか?",
middleText: "これにより、アカウント情報とすべてのゲーム データが削除され、すべての状態が削除されます",
textConfirm: "確認する",
textCancel: "キャンセルする",
onCancel: () => Get.back(),
onConfirm: () {
String token = indexController.currentUser[0]['token'];
AuthService.deleteUser(token).then((value) {
Get.snackbar("ユーザーデータを削除する",
"データを削除するためにユーザーの同意が設定されています アプリとサーバーでユーザーデータが削除されました");
});
});
},
)
: const SizedBox(
width: 0,
height: 0,
),
// ListTile(
// leading: const Icon(Icons.person),
// title: Text("profile".tr),
// onTap: (){},
// ),
// ListTile(
// leading: const Icon(Icons.route),
// title: Text("recommended_route".tr),
// onTap: (){},
// ),
// ListTile(
// leading: const Icon(Icons.favorite_rounded),
// title: Text("point_rank".tr),
// onTap: (){},
// ),
*/
// "rog_web".trというテキストのアイテムは、ユーザーがログインしている場合にのみ表示されます。
// タップすると、_launchURL()メソッドを呼び出して外部のウェブサイトを開きます。
indexController.currentUser.isNotEmpty
? ListTile(
leading: const Icon(Icons.featured_video),
@ -264,8 +206,6 @@ class DrawerPage extends StatelessWidget {
width: 0,
height: 0,
),
// "privacy".trというテキストのアイテムは、常に表示されます。
// タップすると、_launchURL()メソッドを呼び出してプライバシーポリシーのURLを開きます。
ListTile(
leading: const Icon(Icons.privacy_tip),
title: Text("privacy".tr),
@ -275,7 +215,7 @@ class DrawerPage extends StatelessWidget {
),
ListTile(
leading: const Icon(Icons.settings),
title: const Text('設定'),
title: Text('open_settings'.tr),
onTap: () {
Get.back(); // ドロワーを閉じる
Get.toNamed(Routes.SETTINGS);
@ -284,7 +224,7 @@ class DrawerPage extends StatelessWidget {
),
ListTile(
leading: const Icon(Icons.developer_mode),
title: const Text('開発者メニュー'),
title: const Text('open_settings'),
onTap: () {
Get.back(); // ドロワーを閉じる
Get.toNamed('/debug'); // デバッグ画面に遷移

View File

@ -15,6 +15,7 @@ import 'package:rogapp/routes/app_pages.dart';
import 'package:rogapp/services/auth_service.dart';
import 'package:rogapp/services/location_service.dart';
import 'package:rogapp/utils/database_helper.dart';
import 'package:rogapp/widgets/debug_widget.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../main.dart';
@ -52,6 +53,8 @@ class IndexController extends GetxController with WidgetsBindingObserver {
MapController mapController = MapController();
MapController rogMapController = MapController();
LogManager logManager = LogManager();
String? userToken;
// mode = 0 is map mode, mode = 1 list mode
@ -155,14 +158,15 @@ class IndexController extends GetxController with WidgetsBindingObserver {
return WillPopScope(
onWillPop: () async => false,
child: AlertDialog(
title: Text('位置情報の許可が必要です'),
content: Text('設定>プライバシーとセキュリティ>位置情報サービス を開いて、岐阜ナビを探し、「位置情報の許可」を「常に」にして下さい。'),
title: Text('location_permission_needed_title'.tr),
content: Text('location_permission_needed_main'.tr),
actions: [
TextButton(
onPressed: () {
logManager.addOperationLog("User tapped confirm button for location permission required.");
Navigator.of(context).pop();
},
child: Text('OK'),
child: Text('confirm'.tr),
),
],
),
@ -230,6 +234,7 @@ class IndexController extends GetxController with WidgetsBindingObserver {
void _startLocationService() async {
const platform = MethodChannel('location');
try {
logManager.addOperationLog("Called start location service.");
await platform.invokeMethod('startLocationService');
} on PlatformException catch (e) {
print("Failed to start location service: '${e.message}'.");
@ -239,6 +244,7 @@ class IndexController extends GetxController with WidgetsBindingObserver {
void _stopLocationService() async {
const platform = MethodChannel('location');
try {
logManager.addOperationLog("Called stop location service.");
await platform.invokeMethod('stopLocationService');
} on PlatformException catch (e) {
print("Failed to stop location service: '${e.message}'.");
@ -301,24 +307,29 @@ class IndexController extends GetxController with WidgetsBindingObserver {
}
}
logManager.addOperationLog("Called boundsFromLatLngList (${x1!},${y1!})-(${x0!},${y0!}).");
return LatLngBounds(LatLng(x1!, y1!), LatLng(x0!, y0!));
}
// 要検討:エラーハンドリングが行われていますが、エラーメッセージをローカライズすることを検討してください。
//
void login(String email, String password, BuildContext context) {
AuthService.login(email, password).then((value) {
print("------- logged in user details ######## $value ###### --------");
if (value.isNotEmpty) {
logManager.addOperationLog("User logged in : ${value}.");
// Navigator.pop(context);
print("--------- user details login ----- $value");
//await Future.delayed(const Duration(milliseconds: 500)); // Added Akira:2024-4-6, #2800
changeUser(value);
} else {
logManager.addOperationLog("User failed login : ${email} , ${password}.");
isLoading.value = false;
Get.snackbar(
"ログイン失敗",
"ログインIDかパスワードを確認して下さい。",
"login_failed".tr,
"check_login_id_or_password".tr,
backgroundColor: Colors.red,
colorText: Colors.white,
icon: const Icon(Icons.error, size: 40.0, color: Colors.blue),
@ -340,6 +351,7 @@ class IndexController extends GetxController with WidgetsBindingObserver {
AuthService.changePassword(oldpassword, newpassword, token).then((value) {
////print("------- change password ######## $value ###### --------");
if (value.isNotEmpty) {
logManager.addOperationLog("User successed to change password : ${oldpassword} , ${newpassword}.");
isLoading.value = false;
Navigator.pop(context);
if (rogMode.value == 1) {
@ -348,6 +360,7 @@ class IndexController extends GetxController with WidgetsBindingObserver {
switchPage(AppPages.INDEX);
}
} else {
logManager.addOperationLog("User failed to change password : ${oldpassword} , ${newpassword}.");
Get.snackbar(
'failed'.tr,
'password_change_failed_please_try_again'.tr,
@ -383,6 +396,7 @@ class IndexController extends GetxController with WidgetsBindingObserver {
*/
void logout() async {
logManager.addOperationLog("User logout : ${currentUser} .");
saveGameState();
locations.clear();
DatabaseHelper db = DatabaseHelper.instance;
@ -403,12 +417,15 @@ class IndexController extends GetxController with WidgetsBindingObserver {
void register(String email, String password, BuildContext context) {
AuthService.register(email, password).then((value) {
if (value.isNotEmpty) {
logManager.addOperationLog("User tried to register new account : ${email} , ${password} .");
currentUser.clear();
currentUser.add(value);
isLoading.value = false;
Navigator.pop(context);
Get.toNamed(AppPages.INDEX);
} else {
logManager.addOperationLog("User failed to register new account : ${email} , ${password} .");
isLoading.value = false;
Get.snackbar(
'failed'.tr,

View File

@ -122,7 +122,7 @@ class LoginPage extends StatelessWidget {
BorderRadius.circular(40)),
child: Text(
"login".tr,
style: TextStyle(
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
color: Colors.white70),

View File

@ -15,7 +15,7 @@ class RegisterPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: false,
resizeToAvoidBottomInset: true,
backgroundColor: Colors.white,
appBar: AppBar(
elevation: 0,

View File

@ -4,7 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
class ErrorService {
static Future<void> reportError(dynamic error, StackTrace stackTrace, Map<String, dynamic> deviceInfo) async {
static Future<void> reportError(dynamic error, StackTrace stackTrace, Map<String, dynamic> deviceInfo, List<String> operationLogs) async {
try {
final String errorMessage = error.toString();
final String stackTraceString = stackTrace.toString();
@ -19,16 +19,20 @@ class ErrorService {
'stack_trace': stackTraceString,
'estimated_cause': estimatedCause,
'device_info': deviceInfo,
'operation_logs': operationLogs.join('\n'), // オペレーションログを改行で結合して送信
},
);
if (response.statusCode == 200) {
// エラー報告が成功した場合の処理(必要に応じて)
debugPrint("===== エラーログ送信成功しました。 ====");
} else {
// エラー報告が失敗した場合の処理(必要に応じて)
debugPrint("===== エラーログ送信失敗しました。 ====");
}
} catch (e) {
// エラー報告中にエラーが発生した場合の処理(必要に応じて)
debugPrint("===== エラーログ送信中にエラーになりました。 ====");
}
}

View File

@ -7,7 +7,8 @@ class StringValues extends Translations{
'drawer_title':'Rogaining participants can view checkpoints by logging in',
'app_title': '- Rogaining -',
'address':'address',
'email':'Email',
'bib':'Bib number',
'email':'Email address',
'password':'Password',
'web':'Web',
'wikipedia':'Wikipedia',
@ -70,6 +71,8 @@ class StringValues extends Translations{
"Not reached the goal yet": "Not reached the goal yet",
"You have not reached the goal yet.":"You have not reached the goal yet.",
"delete_account": "Delete account",
"delete_account_title": "Are you ok to delete your account?",
"delete_account_middle": "All your account information and data history will be removed from local device and server side.",
"accounted_deleted": "Account deleted",
"account_deleted_message": "your account has beed successfully deleted",
"privacy": "Privacy policy",
@ -95,7 +98,6 @@ class StringValues extends Translations{
'already_have_account': 'Already have an account?',
'sign_up': 'Sign Up',
'create_account': 'Create an account, it\'s free',
'email': 'Email',
'confirm_password': 'Confirm Password',
'cancel_checkin': 'Cancel Check-in',
'go_here': 'Show route',
@ -203,13 +205,19 @@ class StringValues extends Translations{
'location_permission_required_title': 'Location Permission Required',
'location_permission_required_message': 'This app requires access to your location. Please grant permission to continue.',
'cancel': 'Cancel',
'checkins': 'Check-ins'
'checkins': 'Check-ins',
'reset_button': 'Reset data',
'reset_title': 'Reset the data in this device.',
'reset_message': 'Are you ok to reset all data in this device?',
'reset_done': 'Reset Done.',
'reset_explain': 'All data has been reset. You should tap start rogaining to start game.',
},
'ja_JP': {
'drawer_title':'ロゲイニング参加者はログイン するとチェックポイントが参照 できます',
'app_title': '旅行工程表',
'address':'住所',
'email':'ゼッケン番号',
'bib':'ゼッケン番号',
'email':'メールアドレス',
'password':'パスワード',
'web':'ウェブ',
'wikipedia':'ウィキペディア',
@ -245,7 +253,7 @@ class StringValues extends Translations{
'confirm': '確認',
'cancel': 'キャンセル',
'all_destinations_are_deleted_successfully' : 'すべての宛先が正常に削除されました',
'deleted': "削除された",
'deleted': "削除されまし",
'remarks' : '備考',
'old_password' : '以前のパスワード',
'new_password' : '新しいパスワード',
@ -273,11 +281,13 @@ class StringValues extends Translations{
"You have not started rogaining yet.":"あなたはまだロゲイニングを始めていません。",
"Not reached the goal yet": "まだ目標に達していない",
"You have not reached the goal yet.":"あなたはまだゴールに達していません。",
"delete_account": "アカウントを削除す",
"delete_account": "アカウントを削除しま",
"delete_account_title": "アカウントを削除しますがよろしいですか?",
"delete_account_middle": "これにより、アカウント情報とすべてのゲーム データが削除され、すべての状態が削除されます",
"accounted_deleted": "アカウントが削除されました",
"account_deleted_message": "あなたのアカウントは正常に削除されました",
"privacy": "プライバシーポリシー",
"app_developed_by_gifu_dx": "※このアプリは令和年度岐阜県DX補助金事業で開発されました。",
"app_developed_by_gifu_dx": "※このアプリは令和4、6年度岐阜県DX補助金事業で開発されました。",
'location_permission_title': 'ロケーション許可',
'location_permission_content': 'このアプリでは、位置情報の収集を行います。\n岐阜ナビアプリではチェックポイントの自動チェックインの機能を可能にするために、現在地のデータが収集されます。アプリを閉じている時や、使用していないときにも収集されます。位置情報は、個人を特定できない統計的な情報として、ユーザーの個人情報とは一切結びつかない形で送信されます。お知らせの配信、位置情報の利用を許可しない場合は、この後表示されるダイアログで「許可しない」を選択してください。',
@ -299,7 +309,6 @@ class StringValues extends Translations{
'already_have_account': 'すでにアカウントをお持ちですか?',
'sign_up': 'サインアップ',
'create_account': 'アカウントを無料で作成します',
'email': 'ゼッケン番号',
'confirm_password': 'パスワードを認証する',
'cancel_checkin': 'チェックイン取消',
'go_here': 'ルート表示',
@ -410,6 +419,11 @@ class StringValues extends Translations{
'location_permission_required_message': 'このアプリを使用するには、位置情報へのアクセスが必要です。続行するには許可を付与してください。',
'cancel': 'キャンセル',
'checkins': 'チェックイン',
'reset_button': 'リセット',
'reset_title': 'リセットしますがよろしいですか?',
'reset_message': 'これにより、すべてのゲーム データが削除され、すべての状態が削除されます',
'reset_done': 'リセット完了',
'reset_explain': 'すべてリセットされました。ロゲ開始から再開して下さい。',
},
};
}
}

View File

@ -12,6 +12,9 @@ class LogManager {
List<String> _logs = [];
List<VoidCallback> _listeners = [];
List<String> _operationLogs = [];
List<String> get operationLogs => _operationLogs;
List<String> get logs => _logs;
void addLog(String log) {
@ -24,6 +27,16 @@ class LogManager {
_notifyListeners(); // Notify all listeners
}
void addOperationLog(String log) {
_operationLogs.add(log);
_notifyListeners();
}
void clearOperationLogs() {
_operationLogs.clear();
_notifyListeners();
}
void addListener(VoidCallback listener) {
_listeners.add(listener);
}