From b3dd31075997d6a4daf7c1916c4329466cdcf5f6 Mon Sep 17 00:00:00 2001 From: Tom Praschan <13141438+tom-anders@users.noreply.github.com> Date: Tue, 6 Jan 2026 09:47:23 +0100 Subject: [PATCH] Backport fixes/features to 0.18.x (#2482) * feat: update chessground to 7.3.0 (add a board (purple), pieces (xkcd, firi) and fixing staunty knight to be aligned center) (#2380) * Add purple board theme in list (chessground dependency) * Change chessground version to 7.3.0 * feat(otb): add takeback button (#1642) (#2334) * potential fix for notification issues on iOS (#2451) getAPNSToken() is the only iOS specific thing that happens in this file, so I think there's a high probabillity that the issues are related to that. According to https://firebase.google.com/docs/cloud-messaging/flutter/get-started we need to get permission *before* calling getAPNSToken(). Currently it looks like there could be a race condition where the connectivity change listener fires before we call requestPermission(). Fix this by moving the ref.listen() below the requestPermission() call. * Fix register firebase after sign in And make sure _registeredDevice flag is only set when device is actually registered. * fix: remove delay when entering analysis board (#2471) This is an alternate fix for #1232 which does not need the 500ms delay. When entering the analysis board, the delay of 500ms before scrolling to the current move is unexpected for users. With this solution, we can jump to the current move immedialtely. * feat: add log screen (#2463) --------- Co-authored-by: lumiknit Co-authored-by: overcharged <199525476+overcharged-coder@users.noreply.github.com> Co-authored-by: Vincent Velociter --- assets/board-thumbnails/purple.jpg | Bin 0 -> 1241 bytes lib/src/app.dart | 2 + lib/src/binding.dart | 5 +- lib/src/log.dart | 61 ++++++-- .../notifications/notification_service.dart | 47 ++++--- lib/src/model/settings/board_preferences.dart | 3 + lib/src/model/settings/log_preferences.dart | 65 +++++++++ lib/src/utils/lru_list.dart | 33 +++++ .../over_the_board/over_the_board_screen.dart | 18 ++- .../settings/app_log_settings_screen.dart | 130 ++++++++++++++++++ lib/src/view/settings/settings_screen.dart | 11 ++ lib/src/widgets/pgn.dart | 25 ++-- pubspec.lock | 4 +- pubspec.yaml | 2 +- 14 files changed, 346 insertions(+), 60 deletions(-) create mode 100644 assets/board-thumbnails/purple.jpg create mode 100644 lib/src/model/settings/log_preferences.dart create mode 100644 lib/src/utils/lru_list.dart create mode 100644 lib/src/view/settings/app_log_settings_screen.dart diff --git a/assets/board-thumbnails/purple.jpg b/assets/board-thumbnails/purple.jpg new file mode 100644 index 0000000000000000000000000000000000000000..5e2b0269113160996e4bccad6f38a61d65566628 GIT binary patch literal 1241 zcmeH_JqiLb5QX0)?z*5w0wQ*c)`~}1L0NxJA)dek2yX8stgP*9EZoII_*ZxYZxA!f zf+DyUV&|mDOL&y=7h2&2qhIj2WPcp2kAv=$3k56ZOoz&Hru?TH0{R`3td}pc}FYu z-Bk1@$t@L|!mP&PpXO)1u{ { @override void initState() { // Start services + ref.read(appLogStorageServiceProvider).start(); ref.read(notificationServiceProvider).start(); ref.read(messageServiceProvider).start(); ref.read(challengeServiceProvider).start(); diff --git a/lib/src/binding.dart b/lib/src/binding.dart index 5b9e7821c..8bf11ae26 100644 --- a/lib/src/binding.dart +++ b/lib/src/binding.dart @@ -4,7 +4,6 @@ import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:lichess_mobile/firebase_options.dart'; -import 'package:lichess_mobile/src/log.dart'; import 'package:lichess_mobile/src/model/engine/engine.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -89,9 +88,7 @@ abstract class LichessBinding { /// A concrete implementation of [LichessBinding] for the app. class AppLichessBinding extends LichessBinding { - AppLichessBinding() { - setupLogging(); - } + AppLichessBinding(); /// Returns an instance of the binding that implements [LichessBinding]. /// diff --git a/lib/src/log.dart b/lib/src/log.dart index 8fdc8074c..aa2021240 100644 --- a/lib/src/log.dart +++ b/lib/src/log.dart @@ -2,29 +2,60 @@ import 'dart:developer' as developer; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:lichess_mobile/src/model/settings/log_preferences.dart'; +import 'package:lichess_mobile/src/utils/lru_list.dart'; import 'package:logging/logging.dart'; const _loggersToShowInTerminal = {'HttpClient', 'Socket', 'EvaluationService'}; -/// Setup logging -void setupLogging() { - if (kDebugMode) { - Logger.root.level = Level.FINE; - Logger.root.onRecord.listen((record) { - developer.log( - record.message, - time: record.time, - name: record.loggerName, - level: record.level.value, - error: record.error, - stackTrace: record.stackTrace, - ); +/// Provides an instance of [AppLogStorageService] using Riverpod. +final appLogStorageServiceProvider = Provider( + (Ref ref) => AppLogStorageService(ref), + name: 'AppLogStorageServiceProvider', +); - if (_loggersToShowInTerminal.contains(record.loggerName) && record.level >= Level.FINE) { - debugPrint('[${record.loggerName}] ${record.message}'); +/// Manages log entries created via [Logger] instances +/// +/// Currently, simply saves the most recent log entries in memory, so they do not persists across app restarts. +class AppLogStorageService { + AppLogStorageService(this.ref); + + final Ref ref; + final _logs = LRUList(capacity: 1024); + + /// Currently stored log entries, ordered from oldest to newest. + Iterable get logs => _logs.values; + + void start() { + ref.listen(logPreferencesProvider.select((prefs) => prefs.level), (prev, next) { + if (next != prev) { + Logger.root.level = next; } + }, fireImmediately: true); + + Logger.root.onRecord.listen((record) { + if (kDebugMode) { + developer.log( + record.message, + time: record.time, + name: record.loggerName, + level: record.level.value, + error: record.error, + stackTrace: record.stackTrace, + ); + + if (_loggersToShowInTerminal.contains(record.loggerName) && record.level >= Level.FINE) { + debugPrint('[${record.loggerName}] ${record.message}'); + } + } + + _logs.put(record); }); } + + void clear() { + _logs.clear(); + } } class ProviderLogger extends ProviderObserver { diff --git a/lib/src/model/notifications/notification_service.dart b/lib/src/model/notifications/notification_service.dart index 3e7dea710..94f66e8bb 100644 --- a/lib/src/model/notifications/notification_service.dart +++ b/lib/src/model/notifications/notification_service.dart @@ -98,18 +98,6 @@ class NotificationService { /// This method should be called once the app is ready to receive notifications, /// and after [LichessBinding.initializeNotifications] has been called. Future start() async { - // listen for connectivity changes to register device once the app is online - _connectivitySubscription = _ref.listen(connectivityChangesProvider, (prev, current) async { - if (current.value?.isOnline == true && !_registeredDevice) { - try { - await registerDevice(); - _registeredDevice = true; - } catch (e, st) { - _logger.severe('Could not setup push notifications; $e\n$st'); - } - } - }); - // Listen for incoming messages while the app is in the foreground. LichessBinding.instance.firebaseMessagingOnMessage.listen((RemoteMessage message) { _processFcmMessage(message, fromBackground: false); @@ -139,6 +127,20 @@ class NotificationService { _registerToken(token); }); + // listen for connectivity changes to register device once the app is online + // This needs to be done *after* via have gotten permission, otherwise on iOS + // getAPNSToken() might still return null. + _connectivitySubscription = _ref.listen(connectivityChangesProvider, (prev, current) async { + if (current.value?.isOnline == true && !_registeredDevice) { + try { + final success = await registerDevice(); + if (success) _registeredDevice = true; + } catch (e, st) { + _logger.severe('Could not setup push notifications; $e\n$st'); + } + } + }); + // Get any messages which caused the application to open from // a terminated state. final RemoteMessage? initialMessage = await LichessBinding.instance.firebaseMessaging @@ -326,18 +328,23 @@ class NotificationService { } /// Register the device for push notifications. - Future registerDevice() async { + /// + /// Returns true if the device was successfully registered, false otherwise. + Future registerDevice() async { + // For apple platforms, make sure the APNS token is available before making any FCM plugin API calls if (defaultTargetPlatform == TargetPlatform.iOS) { final apnsToken = await LichessBinding.instance.firebaseMessaging.getAPNSToken(); if (apnsToken == null) { _logger.warning('APNS token is null'); - return; + return false; } } final token = await LichessBinding.instance.firebaseMessaging.getToken(); - if (token != null) { - await _registerToken(token); + if (token == null) { + _logger.warning('FCM token is null'); + return false; } + return await _registerToken(token); } /// Unregister the device from push notifications. @@ -354,20 +361,22 @@ class NotificationService { } } - Future _registerToken(String token) async { + Future _registerToken(String token) async { final settings = await LichessBinding.instance.firebaseMessaging.getNotificationSettings(); if (settings.authorizationStatus == AuthorizationStatus.denied) { - return; + return false; } _logger.info('will register fcmToken: $token'); final session = _ref.read(authSessionProvider); if (session == null) { - return; + return false; } try { await _ref.withClient((client) => client.post(Uri(path: '/mobile/register/firebase/$token'))); + return true; } catch (e, st) { _logger.severe('could not register device; $e', e, st); + return false; } } diff --git a/lib/src/model/settings/board_preferences.dart b/lib/src/model/settings/board_preferences.dart index d40d9393b..3ff71dcf5 100644 --- a/lib/src/model/settings/board_preferences.dart +++ b/lib/src/model/settings/board_preferences.dart @@ -318,6 +318,7 @@ enum BoardTheme { metal('Metal', 'metal'), olive('Olive', 'olive'), newspaper('Newspaper', 'newspaper'), + purple('Purple', 'purple'), purpleDiag('Purple-Diag', 'purple-diag'), pinkPyramid('Pink', 'pink'), horsey('Horsey', 'horsey'); @@ -373,6 +374,8 @@ enum BoardTheme { return ChessboardColorScheme.olive; case BoardTheme.newspaper: return ChessboardColorScheme.newspaper; + case BoardTheme.purple: + return ChessboardColorScheme.purple; case BoardTheme.purpleDiag: return ChessboardColorScheme.purpleDiag; case BoardTheme.pinkPyramid: diff --git a/lib/src/model/settings/log_preferences.dart b/lib/src/model/settings/log_preferences.dart new file mode 100644 index 000000000..b8b012ebc --- /dev/null +++ b/lib/src/model/settings/log_preferences.dart @@ -0,0 +1,65 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:lichess_mobile/src/model/settings/preferences_storage.dart'; +import 'package:logging/logging.dart'; + +part 'log_preferences.freezed.dart'; +part 'log_preferences.g.dart'; + +final logPreferencesProvider = NotifierProvider( + LogPreferencesNotifier.new, + name: 'LogPreferencesProvider', +); + +class LogPreferencesNotifier extends Notifier with PreferencesStorage { + @override + @protected + PrefCategory get prefCategory => PrefCategory.overTheBoard; + + @override + @protected + LogPrefs get defaults => LogPrefs.defaults; + + @override + LogPrefs fromJson(Map json) => LogPrefs.fromJson(json); + + @override + LogPrefs build() => fetch(); + + Future setLogLevel(Level level) { + return save(state.copyWith(level: level)); + } +} + +const _kDefaultLevel = kDebugMode ? Level.FINE : Level.WARNING; + +@Freezed(fromJson: true, toJson: true) +sealed class LogPrefs with _$LogPrefs implements Serializable { + const LogPrefs._(); + + const factory LogPrefs({@LevelConverter() required Level level}) = _LogPrefs; + + static const defaults = LogPrefs(level: _kDefaultLevel); + + factory LogPrefs.fromJson(Map json) { + return _$LogPrefsFromJson(json); + } +} + +class LevelConverter implements JsonConverter { + const LevelConverter(); + + @override + Level fromJson(String json) { + try { + final value = int.parse(json); + return Level.LEVELS.firstWhere((level) => level.value == value); + } catch (e) { + return _kDefaultLevel; + } + } + + @override + String toJson(Level object) => object.value.toString(); +} diff --git a/lib/src/utils/lru_list.dart b/lib/src/utils/lru_list.dart new file mode 100644 index 000000000..758ce990c --- /dev/null +++ b/lib/src/utils/lru_list.dart @@ -0,0 +1,33 @@ +import 'dart:collection'; + +/// A simple LRU (Least Recently Used) list implementation +/// +/// The items are ordered by their insertion time, with the most recently added item at the end. +/// If the list exceeds its capacity when extended via [put], the least recently added item is removed. +class LRUList { + final int capacity; + final LinkedList<_LRUListEntry> _list = LinkedList<_LRUListEntry>(); + + LRUList({required this.capacity}); + + /// Add the [value] to the end of the list. If the list exceeds its capacity, the least recently used item is removed. + void put(T value) { + if (_list.length >= capacity) { + _list.first.unlink(); + } + _list.add(_LRUListEntry(value)); + } + + void clear() { + _list.clear(); + } + + /// The values in the list, ordered from least recently used to most recently used. + Iterable get values => _list.map((entry) => entry.value); +} + +final class _LRUListEntry extends LinkedListEntry<_LRUListEntry> { + final T value; + + _LRUListEntry(this.value); +} diff --git a/lib/src/view/over_the_board/over_the_board_screen.dart b/lib/src/view/over_the_board/over_the_board_screen.dart index 7494a8325..e7f334d99 100644 --- a/lib/src/view/over_the_board/over_the_board_screen.dart +++ b/lib/src/view/over_the_board/over_the_board_screen.dart @@ -243,7 +243,6 @@ class _BottomBar extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final gameState = ref.watch(overTheBoardGameControllerProvider); - final clock = ref.watch(overTheBoardClockProvider); return BottomBar( @@ -297,6 +296,20 @@ class _BottomBar extends ConsumerWidget { : null, icon: CupertinoIcons.chevron_forward, ), + BottomBarButton( + label: 'Takeback', + onTap: gameState.canGoBack + ? () { + ref.read(overTheBoardGameControllerProvider.notifier).goBack(); + if (clock.active) { + ref + .read(overTheBoardClockProvider.notifier) + .switchSide(newSideToMove: gameState.turn.opposite, addIncrement: false); + } + } + : null, + icon: Icons.undo, + ), ], ); } @@ -376,9 +389,7 @@ class _Player extends ConsumerWidget { const _Player({required this.clockKey, required this.side, required this.upsideDown}); final Side side; - final Key clockKey; - final bool upsideDown; @override @@ -403,7 +414,6 @@ class _Player extends ConsumerWidget { timeLeft: Duration(milliseconds: max(0, clock.timeLeft(side)!.inMilliseconds)), key: clockKey, active: clock.activeClock == side, - // https://github.com/lichess-org/mobile/issues/785#issuecomment-2183903498 emergencyThreshold: Duration( seconds: (clock.timeIncrement.time * 0.125).clamp(10, 60).toInt(), ), diff --git a/lib/src/view/settings/app_log_settings_screen.dart b/lib/src/view/settings/app_log_settings_screen.dart new file mode 100644 index 000000000..edd50ae06 --- /dev/null +++ b/lib/src/view/settings/app_log_settings_screen.dart @@ -0,0 +1,130 @@ +import 'package:fast_immutable_collections/fast_immutable_collections.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:lichess_mobile/src/log.dart'; +import 'package:lichess_mobile/src/model/settings/log_preferences.dart'; +import 'package:lichess_mobile/src/styles/styles.dart'; +import 'package:lichess_mobile/src/utils/navigation.dart'; +import 'package:lichess_mobile/src/utils/share.dart'; +import 'package:lichess_mobile/src/widgets/adaptive_action_sheet.dart'; +import 'package:lichess_mobile/src/widgets/adaptive_choice_picker.dart'; +import 'package:lichess_mobile/src/widgets/list.dart'; +import 'package:lichess_mobile/src/widgets/settings.dart'; +import 'package:logging/logging.dart'; +import 'package:share_plus/share_plus.dart'; + +final Logger _logger = Logger('AppLogSettingsScreen'); + +final _logDateFormatter = DateFormat.Hms(); + +class AppLogSettingsScreen extends ConsumerWidget { + const AppLogSettingsScreen({super.key}); + + static Route buildRoute(BuildContext context) { + return buildScreenRoute(context, screen: const AppLogSettingsScreen()); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final currentLevel = ref.watch(logPreferencesProvider.select((prefs) => prefs.level)); + final logs = ref.read(appLogStorageServiceProvider).logs.toIList().reversed; + + return Scaffold( + appBar: AppBar( + title: const Text('App Logs'), + actions: [ + IconButton( + tooltip: 'Export', + icon: const Icon(Icons.share), + onPressed: () => launchShareDialog( + context, + ShareParams( + text: logs + .map( + (record) => + '[${_logDateFormatter.format(record.time)}] [${record.loggerName}] ${record.message}', + ) + .join('\n'), + ), + ), + ), + IconButton( + tooltip: 'Delete all logs', + icon: const Icon(Icons.delete_sweep), + onPressed: () { + showConfirmDialog( + context, + title: const Text('Delete all logs'), + onConfirm: ref.read(appLogStorageServiceProvider).clear, + ); + }, + ), + ], + ), + body: Column( + children: [ + ListSection( + children: [ + SettingsListTile( + settingsLabel: const Text('Log Level'), + settingsValue: currentLevel.name, + onTap: () { + showChoicePicker( + context, + choices: Level.LEVELS, + selectedItem: currentLevel, + labelBuilder: (Level l) => Text(l.name), + onSelectedItemChanged: (Level value) { + _logger.fine('Changing log level to ${value.name}'); + ref.read(logPreferencesProvider.notifier).setLogLevel(value); + }, + ); + }, + ), + ], + ), + if (logs.isNotEmpty) + Expanded( + child: Padding( + padding: Styles.bodySectionPadding, + child: Card( + margin: EdgeInsets.zero, + child: ListView.separated( + itemCount: logs.length, + separatorBuilder: (_, _) => const Divider(height: 1, thickness: 0), + itemBuilder: (_, index) => _LogTile(record: logs[index]), + ), + ), + ), + ), + ], + ), + ); + } +} + +class _LogTile extends StatelessWidget { + const _LogTile({required this.record}); + + final LogRecord record; + + @override + Widget build(BuildContext context) { + return ListTile( + dense: true, + leading: SizedBox( + width: 30, + child: Text(record.level.name, style: const TextStyle(fontSize: 12)), + ), + title: Text( + '[${record.loggerName}] ${record.message}', + style: const TextStyle(fontSize: 14, letterSpacing: -0.15), + ), + subtitle: Text( + _logDateFormatter.format(record.time), + style: TextStyle(color: textShade(context, 0.7), fontSize: 12), + ), + ); + } +} diff --git a/lib/src/view/settings/settings_screen.dart b/lib/src/view/settings/settings_screen.dart index d49629400..409883dc1 100644 --- a/lib/src/view/settings/settings_screen.dart +++ b/lib/src/view/settings/settings_screen.dart @@ -14,6 +14,7 @@ import 'package:lichess_mobile/src/utils/l10n_context.dart'; import 'package:lichess_mobile/src/utils/navigation.dart'; import 'package:lichess_mobile/src/view/home/home_tab_screen.dart'; import 'package:lichess_mobile/src/view/settings/account_preferences_screen.dart'; +import 'package:lichess_mobile/src/view/settings/app_log_settings_screen.dart'; import 'package:lichess_mobile/src/view/settings/board_settings_screen.dart'; import 'package:lichess_mobile/src/view/settings/engine_settings_screen.dart'; import 'package:lichess_mobile/src/view/settings/http_log_screen.dart'; @@ -172,6 +173,16 @@ class SettingsScreen extends ConsumerWidget { title: const Text('HTTP logs'), onTap: () => Navigator.push(context, HttpLogScreen.buildRoute(context)), ), + ListTile( + leading: const Icon(Icons.bug_report), + title: const Text('App Logs'), + trailing: Theme.of(context).platform == TargetPlatform.iOS + ? const CupertinoListTileChevron() + : null, + onTap: () { + Navigator.of(context).push(AppLogSettingsScreen.buildRoute(context)); + }, + ), ], ), Padding( diff --git a/lib/src/widgets/pgn.dart b/lib/src/widgets/pgn.dart index f1d641808..f1f35f694 100644 --- a/lib/src/widgets/pgn.dart +++ b/lib/src/widgets/pgn.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:chessground/chessground.dart'; import 'package:collection/collection.dart'; import 'package:dartchess/dartchess.dart'; @@ -176,31 +174,28 @@ class _DebouncedPgnTreeViewState extends ConsumerState { /// When widget.livePath changes rapidly, we debounce the change to avoid rebuilding the whole tree on every received move. late UciPath? pathToLiveMove; - Timer? _scrollTimer; - @override void initState() { super.initState(); pathToCurrentMove = widget.currentPath; pathToLiveMove = widget.livePath; + + // Scrollable.ensureVisible breaks animation when swiping between tabs (see https://github.com/lichess-org/mobile/issues/1232) + // Explicitly use the Scrollable of our TreeView instead, so that it won't affect the TabController's state. WidgetsBinding.instance.addPostFrameCallback((_) { - _scrollTimer?.cancel(); - _scrollTimer = Timer(const Duration(milliseconds: 500), () { - if (currentMoveKey.currentContext != null) { - Scrollable.ensureVisible( - currentMoveKey.currentContext!, - alignment: 0.5, - alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd, - ); - } - }); + if (currentMoveKey.currentContext != null) { + Scrollable.of(currentMoveKey.currentContext!).position.ensureVisible( + currentMoveKey.currentContext!.findRenderObject()!, + alignment: 0.5, + alignmentPolicy: ScrollPositionAlignmentPolicy.keepVisibleAtEnd, + ); + } }); } @override void dispose() { _debounce.cancel(); - _scrollTimer?.cancel(); super.dispose(); } diff --git a/pubspec.lock b/pubspec.lock index ebe3b06a3..70a061efe 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -205,10 +205,10 @@ packages: dependency: "direct main" description: name: chessground - sha256: "61f95b9186eef3e232c2e95789b5631342c8019542c5529e181f72da37857684" + sha256: "7f706292af797037f55277e256726c2ad2a271cbbb9760959064dc7a376b6ea5" url: "https://pub.dev" source: hosted - version: "7.2.0" + version: "7.3.0" ci: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 34a0fb9c7..37e978444 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,7 +14,7 @@ dependencies: async: ^2.10.0 auto_size_text: ^3.0.0 cached_network_image: ^3.2.2 - chessground: ^7.2.0 + chessground: ^7.3.0 clock: ^1.1.1 collection: ^1.17.0 connectivity_plus: ^7.0.0