diff --git a/assets/board-thumbnails/purple.jpg b/assets/board-thumbnails/purple.jpg new file mode 100644 index 000000000..5e2b02691 Binary files /dev/null and b/assets/board-thumbnails/purple.jpg differ diff --git a/lib/src/app.dart b/lib/src/app.dart index 80d56c37a..d3e57a4d3 100644 --- a/lib/src/app.dart +++ b/lib/src/app.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:l10n_esperanto/l10n_esperanto.dart'; import 'package:lichess_mobile/l10n/l10n.dart'; import 'package:lichess_mobile/src/app_links.dart'; +import 'package:lichess_mobile/src/log.dart'; import 'package:lichess_mobile/src/model/account/account_service.dart'; import 'package:lichess_mobile/src/model/account/ongoing_game.dart'; import 'package:lichess_mobile/src/model/challenge/challenge_service.dart'; @@ -65,6 +66,7 @@ class _AppState extends ConsumerState { @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