import 'dart:async'; import 'dart:io'; import 'dart:ui'; import 'package:app_links/app_links.dart'; import 'package:bitsdojo_window/bitsdojo_window.dart'; import 'package:fluent_ui/fluent_ui.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart' show MaterialPage; import 'package:get/get.dart'; import 'package:reboot_common/common.dart'; import 'package:reboot_launcher/src/controller/backend_controller.dart'; import 'package:reboot_launcher/src/controller/hosting_controller.dart'; import 'package:reboot_launcher/src/controller/settings_controller.dart'; import 'package:reboot_launcher/src/controller/update_controller.dart'; import 'package:reboot_launcher/src/dialog/abstract/dialog.dart'; import 'package:reboot_launcher/src/dialog/abstract/info_bar.dart'; import 'package:reboot_launcher/src/dialog/implementation/dll.dart'; import 'package:reboot_launcher/src/dialog/implementation/server.dart'; import 'package:reboot_launcher/src/page/abstract/page.dart'; import 'package:reboot_launcher/src/page/abstract/page_suggestion.dart'; import 'package:reboot_launcher/src/page/pages.dart'; import 'package:reboot_launcher/src/util/dll.dart'; import 'package:reboot_launcher/src/util/matchmaker.dart'; import 'package:reboot_launcher/src/util/os.dart'; import 'package:reboot_launcher/src/util/translations.dart'; import 'package:reboot_launcher/src/widget/info_bar_area.dart'; import 'package:reboot_launcher/src/widget/profile_tile.dart'; import 'package:reboot_launcher/src/widget/title_bar.dart'; import 'package:window_manager/window_manager.dart'; class HomePage extends StatefulWidget { const HomePage({Key? key}) : super(key: key); @override State createState() => _HomePageState(); } class _HomePageState extends State with WindowListener, AutomaticKeepAliveClientMixin { static const double _kDefaultPadding = 12.0; final BackendController _backendController = Get.find(); final HostingController _hostingController = Get.find(); final SettingsController _settingsController = Get.find(); final UpdateController _updateController = Get.find(); final GlobalKey _searchKey = GlobalKey(); final FocusNode _searchFocusNode = FocusNode(); final TextEditingController _searchController = TextEditingController(); final RxBool _focused = RxBool(true); @override bool get wantKeepAlive => true; @override void initState() { super.initState(); windowManager.addListener(this); WidgetsBinding.instance.addPostFrameCallback((_) { _checkUpdates(); _initAppLink(); _checkGameServer(); }); } void _initAppLink() async { final appLinks = AppLinks(); final initialUrl = await appLinks.getInitialLink(); if(initialUrl != null) { _joinServer(initialUrl); } appLinks.uriLinkStream.listen(_joinServer); } void _joinServer(Uri uri) { final uuid = uri.host; final server = _hostingController.findServerById(uuid); if(server != null) { _backendController.joinServer(_hostingController.uuid, server); }else { showInfoBar( translations.noServerFound, duration: infoBarLongDuration, severity: InfoBarSeverity.error ); } } Future _checkGameServer() async { try { final address = _backendController.gameServerAddress.text; if(isLocalHost(address)) { return; } var result = await pingGameServer(address); if(result) { return; } var oldOwner = _backendController.gameServerOwner.value; _backendController.joinLocalHost(); WidgetsBinding.instance.addPostFrameCallback((_) => showInfoBar( oldOwner == null ? translations.serverNoLongerAvailableUnnamed : translations.serverNoLongerAvailable(oldOwner), severity: InfoBarSeverity.warning, duration: infoBarLongDuration )); }catch(_) { // Intended behaviour // Just ignore the error } } void _checkUpdates() { _updateController.notifyLauncherUpdate(); if(!dllsDirectory.existsSync()) { dllsDirectory.createSync(recursive: true); } for(final injectable in InjectableDll.values) { downloadCriticalDllInteractive( injectable.path, silent: true ); } watchDlls().listen((filePath) => showDllDeletedDialog(() { downloadCriticalDllInteractive(filePath); })); } @override void onWindowClose() { exit(0); // Force closing } @override void dispose() { _searchFocusNode.dispose(); _searchController.dispose(); pagesController.close(); windowManager.removeListener(this); super.dispose(); } @override void onWindowFocus() { _focused.value = true; } @override void onWindowBlur() { _focused.value = false; } @override void onWindowDocked() { _focused.value = true; } @override void onWindowMaximize() { _focused.value = true; } @override void onWindowMinimize() { _focused.value = false; } @override void onWindowResize() { _focused.value = true; } @override void onWindowMove() { _focused.value = true; } @override void onWindowRestore() { _focused.value = true; } @override void onWindowUndocked() { _focused.value = true; } @override void onWindowUnmaximize() { _focused.value = true; } @override void onWindowResized() { _settingsController.saveWindowSize(appWindow.size); _focused.value = true; } @override void onWindowMoved() { _settingsController.saveWindowOffset(appWindow.position); _focused.value = true; } @override void onWindowEnterFullScreen() { _focused.value = true; } @override void onWindowLeaveFullScreen() { _focused.value = true; } @override Widget build(BuildContext context) { super.build(context); _settingsController.language.value; loadTranslations(context); return Obx(() { return NavigationPaneTheme( data: NavigationPaneThemeData( backgroundColor: FluentTheme.of(context).micaBackgroundColor.withOpacity(0.93), ), child: NavigationView( paneBodyBuilder: (pane, body) => _PaneBody( padding: _kDefaultPadding, controller: pagesController, body: body ), appBar: NavigationAppBar( height: 32, title: _draggableArea, actions: WindowTitleBar(focused: _focused()), leading: _backButton, automaticallyImplyLeading: false, ), pane: NavigationPane( selected: pageIndex.value, onChanged: (index) { final lastPageIndex = pageIndex.value; if(lastPageIndex != index) { pageIndex.value = index; }else if(pageStack.isNotEmpty) { Navigator.of(pageKey.currentContext!).pop(); final element = pageStack.removeLast(); appStack.remove(element); pagesController.add(null); } }, menuButton: const SizedBox(), displayMode: PaneDisplayMode.open, items: _items, customPane: _CustomPane(_settingsController), header: const ProfileWidget(), autoSuggestBox: _autoSuggestBox, indicator: const StickyNavigationIndicator( duration: Duration(milliseconds: 500), curve: Curves.easeOut, indicatorSize: 3.25 ) ), contentShape: const RoundedRectangleBorder(), onOpenSearch: () => _searchFocusNode.requestFocus(), transitionBuilder: (child, animation) => child ) ); }); } Widget get _backButton => StreamBuilder( stream: pagesController.stream, builder: (context, _) => Button( style: ButtonStyle( padding: ButtonState.all(const EdgeInsets.only(top: 6.0)), backgroundColor: ButtonState.all(Colors.transparent), shape: ButtonState.all(Border()) ), onPressed: appStack.isEmpty && !inDialog ? null : () { if(inDialog) { Navigator.of(appKey.currentContext!).pop(); }else { final lastPage = appStack.removeLast(); pageStack.remove(lastPage); if (lastPage is int) { hitBack = true; pageIndex.value = lastPage; } else { Navigator.of(pageKey.currentContext!).pop(); } } pagesController.add(null); }, child: const Icon(FluentIcons.back, size: 12.0), ) ); GestureDetector get _draggableArea => GestureDetector( onDoubleTap: appWindow.maximizeOrRestore, onHorizontalDragStart: (_) => appWindow.startDragging(), onVerticalDragStart: (_) => appWindow.startDragging() ); Widget get _autoSuggestBox => Obx(() { final firstRun = _settingsController.firstRun.value; return Padding( padding: const EdgeInsets.symmetric( horizontal: 16.0, vertical: 8.0 ), child: AutoSuggestBox( key: _searchKey, controller: _searchController, enabled: !firstRun, placeholder: translations.find, focusNode: _searchFocusNode, selectionHeightStyle: BoxHeightStyle.max, itemBuilder: (context, item) => ListTile( onPressed: () { pageIndex.value = item.value.pageIndex; _searchController.clear(); _searchFocusNode.unfocus(); }, leading: item.child, title: Text( item.value.name, overflow: TextOverflow.clip, maxLines: 1 ) ), items: _suggestedItems, autofocus: true, trailingIcon: IgnorePointer( child: IconButton( onPressed: () {}, icon: Transform.flip( flipX: true, child: const Icon(FluentIcons.search) ), ) ), ) ); }); List> get _suggestedItems => pages.mapMany((page) { final pageIcon = SizedBox.square( dimension: 24, child: Image.asset(page.iconAsset) ); final results = >[]; results.add(AutoSuggestBoxItem( value: PageSuggestion( name: page.name, description: "", pageIndex: page.index ), label: page.name, child: pageIcon )); return results; }).toList(); List get _items => pages.map((page) => _createItem(page)).toList(); NavigationPaneItem _createItem(RebootPage page) => PaneItem( title: Text(page.name), icon: SizedBox.square( dimension: 24, child: Image.asset(page.iconAsset) ), body: page ); } class _PaneBody extends StatefulWidget { const _PaneBody({ required this.padding, required this.controller, required this.body }); final double padding; final StreamController controller; final Widget? body; @override State<_PaneBody> createState() => _PaneBodyState(); } class _PaneBodyState extends State<_PaneBody> with AutomaticKeepAliveClientMixin { final SettingsController _settingsController = Get.find(); final PageController _pageController = PageController(keepPage: true); @override bool get wantKeepAlive => true; @override void initState() { super.initState(); pageIndex.listen((index) => _pageController.jumpToPage(index)); } @override Widget build(BuildContext context) { super.build(context); final themeMode = _settingsController.themeMode.value; final inactiveColor = themeMode == ThemeMode.dark || (themeMode == ThemeMode.system && isDarkMode) ? Colors.grey[60] : Colors.grey[100]; return Padding( padding: EdgeInsets.only( left: widget.padding, right: widget.padding * 2, top: widget.padding, bottom: widget.padding * 2 ), child: Column( children: [ Expanded( child: ConstrainedBox( constraints: BoxConstraints( maxWidth: 1000 ), child: Center( child: Column( children: [ Align( alignment: Alignment.centerLeft, child: StreamBuilder( stream: widget.controller.stream, builder: (context, _) { final elements = []; elements.add(TextSpan( text: pages[pageIndex.value].name, recognizer: pageStack.isNotEmpty ? (TapGestureRecognizer()..onTap = () { for(var i = 0; i < pageStack.length; i++) { Navigator.of(pageKey.currentContext!).pop(); final element = pageStack.removeLast(); appStack.remove(element); } widget.controller.add(null); }) : null, style: TextStyle( color: pageStack.isNotEmpty ? inactiveColor : null ) )); for(var i = pageStack.length - 1; i >= 0; i--) { var innerPage = pageStack.elementAt(i); innerPage = innerPage.substring(innerPage.indexOf("_") + 1); elements.add(TextSpan( text: " > ", style: TextStyle( color: inactiveColor ) )); elements.add(TextSpan( text: innerPage, recognizer: i == pageStack.length - 1 ? null : (TapGestureRecognizer()..onTap = () { for(var j = 0; j < i - 1; j++) { Navigator.of(pageKey.currentContext!).pop(); final element = pageStack.removeLast(); appStack.remove(element); } widget.controller.add(null); }), style: TextStyle( color: i == pageStack.length - 1 ? null : inactiveColor ) )); } return Text.rich( TextSpan( children: elements ), style: TextStyle( fontSize: 32.0, fontWeight: FontWeight.w600 ), ); } ), ), const SizedBox(height: 24.0), Expanded( child: Stack( fit: StackFit.loose, children: [ PageView.builder( controller: _pageController, itemBuilder: (context, index) => Navigator( onPopPage: (page, data) => true, observers: [ _NestedPageObserver( onChanged: (routeName) { if(routeName != null) { pageIndex.refresh(); addSubPageToStack(routeName); widget.controller.add(null); } } ) ], pages: [ MaterialPage( child: KeyedSubtree( key: getPageKeyByIndex(index), child: widget.body ?? const SizedBox.shrink() ) ) ], ), itemCount: pages.length ), InfoBarArea( key: infoBarAreaKey ) ], ) ), ], ), ), ), ) ], ) ); } } class _CustomPane extends NavigationPaneWidget { final SettingsController settingsController; _CustomPane(this.settingsController); @override Widget build(BuildContext context, NavigationPaneWidgetData data) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ data.appBar, Expanded( child: Navigator( key: appKey, onPopPage: (page, data) => false, pages: [ MaterialPage( child: Row( children: [ SizedBox( width: 310, child: Column( mainAxisSize: MainAxisSize.max, children: [ data.pane.header ?? const SizedBox.shrink(), data.pane.autoSuggestBox ?? const SizedBox.shrink(), const SizedBox(height: 12.0), Expanded( child: Padding( padding: const EdgeInsets.symmetric( horizontal: 16.0 ), child: Scrollbar( controller: data.scrollController, child: ListView.separated( controller: data.scrollController, itemCount: data.pane.items.length, separatorBuilder: (context, index) => const SizedBox( height: 4.0 ), itemBuilder: (context, index) { final item = data.pane.items[index] as PaneItem; return Obx(() { final firstRun = settingsController.firstRun.value; return HoverButton( onPressed: firstRun ? null : () => data.pane.onChanged?.call(index), builder: (context, states) => Container( height: 36, decoration: BoxDecoration( color: ButtonThemeData.uncheckedInputColor( FluentTheme.of(context), item == data.pane.selectedItem ? {ButtonStates.hovering} : states, transparentWhenNone: true, ), borderRadius: BorderRadius.all(Radius.circular(6.0)) ), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 8.0 ), child: Row( children: [ data.pane.indicator ?? const SizedBox.shrink(), item.icon, const SizedBox(width: 12.0), item.title ?? const SizedBox.shrink() ], ), ), ), ); }); }, ), ), ), ) ], ), ), Expanded( child: data.content ) ], ) ) ], ), ) ], ); } class _NestedPageObserver extends NavigatorObserver { final void Function(String?) onChanged; _NestedPageObserver({required this.onChanged}); @override void didPush(Route route, Route? previousRoute) { if(previousRoute != null) { WidgetsBinding.instance.addPostFrameCallback((_) => onChanged(route.settings.name)); } } }