diff --git a/lib/common/widgets/focusable_widget.dart b/lib/common/widgets/focusable_widget.dart new file mode 100644 index 0000000000..8987547f04 --- /dev/null +++ b/lib/common/widgets/focusable_widget.dart @@ -0,0 +1,39 @@ +import 'package:dpad/dpad.dart'; +import 'package:flutter/material.dart'; + +class FocusableWidget extends StatelessWidget { + final Widget child; + final VoidCallback? onSelect; + final bool autofocus; + final bool selected; + + const FocusableWidget({ + super.key, + required this.child, + this.onSelect, + this.autofocus = false, + this.selected = false, + }); + + @override + Widget build(BuildContext context) { + return DpadFocusable( + autofocus: autofocus, + onSelect: onSelect, + builder: (context, isFocused, child) { + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + border: Border.all( + color: isFocused ? Colors.blue : (selected ? Colors.grey : Colors.transparent), + width: 2, + ), + borderRadius: BorderRadius.circular(8), + ), + child: child, + ); + }, + child: child, + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 4d06e4e906..a0f5f2ca9f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'package:PiliPlus/plugin/pl_player/controller.dart'; import 'package:PiliPlus/router/app_pages.dart'; import 'package:PiliPlus/services/account_service.dart'; import 'package:PiliPlus/services/download/download_service.dart'; +import 'package:PiliPlus/services/focus_management_service.dart'; import 'package:PiliPlus/services/service_locator.dart'; import 'package:PiliPlus/utils/app_scheme.dart'; import 'package:PiliPlus/utils/cache_manager.dart'; @@ -25,6 +26,7 @@ import 'package:PiliPlus/utils/storage_pref.dart'; import 'package:PiliPlus/utils/theme_utils.dart'; import 'package:PiliPlus/utils/utils.dart'; import 'package:catcher_2/catcher_2.dart'; +import 'package:dpad/dpad.dart'; import 'package:dynamic_color/dynamic_color.dart'; import 'package:flex_seed_scheme/flex_seed_scheme.dart'; import 'package:flutter/foundation.dart'; @@ -84,7 +86,8 @@ void main() async { } Get ..lazyPut(AccountService.new) - ..lazyPut(DownloadService.new); + ..lazyPut(DownloadService.new) + ..lazyPut(FocusManagementService.new); HttpOverrides.global = _CustomHttpOverrides(); CacheManager.autoClearCache(); @@ -217,6 +220,18 @@ class MyApp extends StatelessWidget { static ThemeData? darkThemeData; static void _onBack() { + final focusService = Get.find(); + if (focusService.handleBackButton()) { + return; + } + + if (focusService.currentState?.level == FocusLevel.lvl1) { + SmartDialog.showToast('再按一次退出应用'); + // In a real app, you'd handle the double-press logic here. + // For now, we'll just show the toast. + return; + } + if (SmartDialog.checkExist()) { SmartDialog.dismiss(); return; @@ -257,7 +272,8 @@ class MyApp extends StatelessWidget { }) { late final brandColor = colorThemeTypes[Pref.customColor].color; late final variant = FlexSchemeVariant.values[Pref.schemeVariant]; - return GetMaterialApp( + return DpadNavigator( + child: GetMaterialApp( title: Constants.appName, theme: ThemeUtils.getThemeData( colorScheme: @@ -339,7 +355,7 @@ class MyApp extends StatelessWidget { if (Utils.isDesktop) PointerDeviceKind.mouse, }, ), - ); + )); } @override diff --git a/lib/pages/dynamics/view.dart b/lib/pages/dynamics/view.dart index ee899ff983..69bd10fd87 100644 --- a/lib/pages/dynamics/view.dart +++ b/lib/pages/dynamics/view.dart @@ -1,6 +1,8 @@ +import 'package:PiliPlus/common/widgets/focusable_widget.dart'; import 'package:PiliPlus/common/widgets/scroll_physics.dart'; import 'package:PiliPlus/http/loading_state.dart'; import 'package:PiliPlus/models/common/dynamic/dynamics_type.dart'; +import 'package:PiliPlus/services/focus_management_service.dart'; import 'package:PiliPlus/models/common/dynamic/up_panel_position.dart'; import 'package:PiliPlus/models/dynamics/up.dart'; import 'package:PiliPlus/pages/dynamics/controller.dart'; @@ -124,7 +126,21 @@ class _DynamicsPageState extends State TabBarTheme.of(context).labelStyle?.copyWith(fontSize: 13) ?? const TextStyle(fontSize: 13), tabs: _dynamicsController.displayedTabs - .map((e) => Tab(text: e.label)) + .map((e) { + final index = _dynamicsController.displayedTabs.indexOf(e); + return FocusableWidget( + onSelect: () { + _dynamicsController.tabController.animateTo(index); + Get.find().requestFocus( + FocusState( + level: index == 0 ? FocusLevel.lvl3 : FocusLevel.lvl4, + index: index, + ), + ); + }, + child: Tab(text: e.label), + ); + }) .toList(), onTap: (index) { if (!_dynamicsController.tabController.indexIsChanging) { diff --git a/lib/pages/main/view.dart b/lib/pages/main/view.dart index be550a308c..d7bb7d842f 100644 --- a/lib/pages/main/view.dart +++ b/lib/pages/main/view.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:PiliPlus/common/constants.dart'; import 'package:PiliPlus/common/widgets/flutter/tabs.dart'; +import 'package:PiliPlus/common/widgets/focusable_widget.dart'; import 'package:PiliPlus/common/widgets/image/network_img_layer.dart'; import 'package:PiliPlus/models/common/dynamic/dynamic_badge_mode.dart'; import 'package:PiliPlus/models/common/image_type.dart'; @@ -11,6 +12,7 @@ import 'package:PiliPlus/pages/main/controller.dart'; import 'package:PiliPlus/pages/mine/controller.dart'; import 'package:PiliPlus/plugin/pl_player/controller.dart'; import 'package:PiliPlus/plugin/pl_player/models/play_status.dart'; +import 'package:PiliPlus/services/focus_management_service.dart'; import 'package:PiliPlus/utils/app_scheme.dart'; import 'package:PiliPlus/utils/context_ext.dart'; import 'package:PiliPlus/utils/extension.dart'; @@ -252,14 +254,29 @@ class _MainAppState extends State selectedIndex: _mainController.selectedIndex.value, destinations: _mainController.navigationBars .map( - (e) => NavigationDestination( - label: e.label, - icon: _buildIcon(type: e), - selectedIcon: _buildIcon( - type: e, - selected: true, - ), - ), + (e) { + final index = _mainController.navigationBars.indexOf(e); + return FocusableWidget( + onSelect: () { + Get.find().requestFocus( + FocusState( + level: index == 0 ? FocusLevel.lvl1 : FocusLevel.lvl2, + index: index, + ), + debounce: true, + ); + _mainController.setIndex(index); + }, + child: NavigationDestination( + label: e.label, + icon: _buildIcon(type: e), + selectedIcon: _buildIcon( + type: e, + selected: true, + ), + ), + ); + }, ) .toList(), ), @@ -274,14 +291,41 @@ class _MainAppState extends State type: BottomNavigationBarType.fixed, items: _mainController.navigationBars .map( - (e) => BottomNavigationBarItem( - label: e.label, - icon: _buildIcon(type: e), - activeIcon: _buildIcon( - type: e, - selected: true, - ), - ), + (e) { + final index = _mainController.navigationBars.indexOf(e); + return BottomNavigationBarItem( + label: e.label, + icon: FocusableWidget( + onSelect: () { + Get.find().requestFocus( + FocusState( + level: index == 0 ? FocusLevel.lvl1 : FocusLevel.lvl2, + index: index, + ), + debounce: true, + ); + _mainController.setIndex(index); + }, + child: _buildIcon(type: e), + ), + activeIcon: FocusableWidget( + onSelect: () { + Get.find().requestFocus( + FocusState( + level: index == 0 ? FocusLevel.lvl1 : FocusLevel.lvl2, + index: index, + ), + debounce: true, + ); + _mainController.setIndex(index); + }, + child: _buildIcon( + type: e, + selected: true, + ), + ), + ); + }, ) .toList(), ), diff --git a/lib/pages/setting/view.dart b/lib/pages/setting/view.dart index d55b7746db..5d186fc893 100644 --- a/lib/pages/setting/view.dart +++ b/lib/pages/setting/view.dart @@ -1,4 +1,5 @@ import 'package:PiliPlus/common/widgets/flutter/list_tile.dart'; +import 'package:PiliPlus/common/widgets/focusable_widget.dart'; import 'package:PiliPlus/common/widgets/view_safe_area.dart'; import 'package:PiliPlus/http/login.dart'; import 'package:PiliPlus/models/common/setting_type.dart'; @@ -15,6 +16,7 @@ import 'package:PiliPlus/pages/webdav/view.dart'; import 'package:PiliPlus/utils/accounts.dart'; import 'package:PiliPlus/utils/accounts/account.dart'; import 'package:PiliPlus/utils/extension.dart'; +import 'package:dpad/dpad.dart'; import 'package:flutter/material.dart' hide ListTile; import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; import 'package:get/get.dart' hide ContextExtensionss; @@ -177,7 +179,9 @@ class _SettingPageState extends State { ..._items .take(_items.length - 1) .map( - (item) => ListTile( + (item) => FocusableWidget( + onSelect: () => _toPage(item.type), + child: ListTile( tileColor: _getTileColor(theme, item.type), onTap: () => _toPage(item.type), leading: item.icon, @@ -185,28 +189,34 @@ class _SettingPageState extends State { subtitle: item.subtitle == null ? null : Text(item.subtitle!, style: subTitleStyle), - ), + )), ), - ListTile( + FocusableWidget( + onSelect: () => LoginPageController.switchAccountDialog(context), + child: ListTile( onTap: () => LoginPageController.switchAccountDialog(context), leading: const Icon(Icons.switch_account_outlined), title: Text('设置账号模式', style: titleStyle), - ), + )), Obx( () => _noAccount.value ? const SizedBox.shrink() - : ListTile( + : FocusableWidget( + onSelect: () => _logoutDialog(context), + child: ListTile( leading: const Icon(Icons.logout_outlined), onTap: () => _logoutDialog(context), title: Text('退出登录', style: titleStyle), - ), + )), ), - ListTile( + FocusableWidget( + onSelect: () => _toPage(_items.last.type), + child: ListTile( tileColor: _getTileColor(theme, _items.last.type), onTap: () => _toPage(_items.last.type), leading: _items.last.icon, title: Text(_items.last.type.title, style: titleStyle), - ), + )), ], ); } @@ -284,7 +294,9 @@ class _SettingPageState extends State { right: 16, bottom: 8, ), - child: Material( + child: FocusableWidget( + onSelect: () => Get.toNamed('/settingsSearch'), + child: Material( type: MaterialType.transparency, child: InkWell( onTap: () => Get.toNamed('/settingsSearch'), @@ -309,6 +321,6 @@ class _SettingPageState extends State { ), ), ), - ), + )), ); } diff --git a/lib/services/focus_management_service.dart b/lib/services/focus_management_service.dart new file mode 100644 index 0000000000..e7382383ec --- /dev/null +++ b/lib/services/focus_management_service.dart @@ -0,0 +1,88 @@ +import 'dart:async'; +import 'package:flutter/widgets.dart'; +import 'package:get/get.dart'; + +enum FocusLevel { + lvl1, // Default bottom bar item + lvl2, // Non-default bottom bar item + lvl3, // Page content - default tab + lvl4, // Page content - non-default tab +} + +class FocusState { + final FocusLevel level; + final int index; + final FocusNode? node; + + FocusState({required this.level, required this.index, this.node}); +} + +class FocusManagementService extends GetxService { + final _focusStack = [].obs; + Timer? _debounce; + + FocusState? get currentState => _focusStack.isNotEmpty ? _focusStack.last : null; + + void requestFocus(FocusState state, {bool debounce = false}) { + if (debounce) { + if (_debounce?.isActive ?? false) _debounce!.cancel(); + _debounce = Timer(const Duration(milliseconds: 300), () { + _updateFocus(state); + }); + } else { + _updateFocus(state); + } + } + + void _updateFocus(FocusState state) { + if (_focusStack.isNotEmpty && _focusStack.last.level == state.level) { + _focusStack.removeLast(); + } + _focusStack.add(state); + state.node?.requestFocus(); + } + + bool handleBackButton() { + if (_focusStack.isEmpty) return false; + + final currentState = _focusStack.last; + switch (currentState.level) { + case FocusLevel.lvl4: + // Go back to default tab + _focusStack.removeLast(); + final pageState = _focusStack.firstWhereOrNull((s) => s.level == FocusLevel.lvl3); + if (pageState != null) { + pageState.node?.requestFocus(); + return true; + } + break; + case FocusLevel.lvl3: + // Go back to bottom bar + _focusStack.removeLast(); + final bottomBarState = _focusStack.firstWhereOrNull((s) => s.level == FocusLevel.lvl1 || s.level == FocusLevel.lvl2); + if (bottomBarState != null) { + bottomBarState.node?.requestFocus(); + return true; + } + break; + case FocusLevel.lvl2: + // Go back to default bottom bar item + _focusStack.removeLast(); + final defaultBottomBarState = _focusStack.firstWhereOrNull((s) => s.level == FocusLevel.lvl1); + if (defaultBottomBarState != null) { + defaultBottomBarState.node?.requestFocus(); + return true; + } + break; + case FocusLevel.lvl1: + // Handle exit confirmation + // This will be handled in the UI + return false; + } + return false; + } + + void clear() { + _focusStack.clear(); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 2678438252..62c8ef1c17 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + dpad: ^1.2.2 # 动态取色 dynamic_color: ^1.8.1