From 0f7d47ef109cbfecaf7ccafeb2a3a3040cbc7efb Mon Sep 17 00:00:00 2001 From: Alessandro Autiero Date: Sun, 4 Jun 2023 00:52:31 +0200 Subject: [PATCH] Minor fixes --- lib/main.dart | 1 - lib/src/cli/game.dart | 2 +- lib/src/ui/page/home_page.dart | 13 +- lib/src/ui/page/hosting_page.dart | 246 ++++++++++-------- lib/src/ui/page/launcher_page.dart | 193 +++++++------- lib/src/ui/page/server_page.dart | 139 +++++----- lib/src/ui/page/settings_page.dart | 3 +- lib/src/ui/widget/home/launch_button.dart | 2 +- lib/src/ui/widget/shared/boxed_pane_item.dart | 31 +++ lib/src/util/server.dart | 14 +- pubspec.yaml | 1 + 11 files changed, 361 insertions(+), 284 deletions(-) create mode 100644 lib/src/ui/widget/shared/boxed_pane_item.dart diff --git a/lib/main.dart b/lib/main.dart index d69556b..0188371 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -43,7 +43,6 @@ void main() async { Get.put(SettingsController()); Get.put(HostingController()); doWhenWindowReady(() { - appWindow.minSize = const Size(kDefaultWindowWidth, kDefaultWindowHeight); var controller = Get.find(); var size = Size(controller.width, controller.height); var window = appWindow as WinDesktopWindow; diff --git a/lib/src/cli/game.dart b/lib/src/cli/game.dart index 9ebecd9..066560f 100644 --- a/lib/src/cli/game.dart +++ b/lib/src/cli/game.dart @@ -36,7 +36,7 @@ Future startGame() async { stdout.writeln("No username was specified, using $username by default. Use --username to specify one"); } - _gameProcess = await Process.start(gamePath, createRebootArgs(username!, host, "")) + _gameProcess = await Process.start(gamePath, createRebootArgs(username!, "", host, "")) ..exitCode.then((_) => _onClose()) ..outLines.forEach((line) => _onGameOutput(line, dll, host, verbose)); } diff --git a/lib/src/ui/page/home_page.dart b/lib/src/ui/page/home_page.dart index dff4582..e73c529 100644 --- a/lib/src/ui/page/home_page.dart +++ b/lib/src/ui/page/home_page.dart @@ -103,6 +103,7 @@ class _HomePageState extends State with WindowListener, AutomaticKeepA appBar: NavigationAppBar( title: _draggableArea, actions: WindowTitleBar(focused: _focused()), + automaticallyImplyLeading: false, leading: _backButton ), pane: NavigationPane( @@ -119,7 +120,8 @@ class _HomePageState extends State with WindowListener, AutomaticKeepA transitionBuilder: (child, animation) => child )) ), - Obx(() => isWin11 && _focused.value ? const WindowBorder() : const SizedBox()) + if(isWin11) + Obx(() => _focused.value ? const WindowBorder() : const SizedBox()) ] ); } @@ -129,13 +131,15 @@ class _HomePageState extends State with WindowListener, AutomaticKeepA entry.value; } + var onBack = _onBack(); return PaneItem( + enabled: onBack != null, icon: const Icon(FluentIcons.back, size: 14.0), body: const SizedBox.shrink(), ).build( context, false, - _onBack(), + onBack, displayMode: PaneDisplayMode.compact ); }); @@ -157,7 +161,10 @@ class _HomePageState extends State with WindowListener, AutomaticKeepA }; } - void _onIndexChanged(int index) => _index.value = index; + void _onIndexChanged(int index) { + _navigationStatus[_index()].value = false; + _index.value = index; + } TextBox get _autoSuggestBox => TextBox( key: _searchKey, diff --git a/lib/src/ui/page/hosting_page.dart b/lib/src/ui/page/hosting_page.dart index e89329c..2f82c54 100644 --- a/lib/src/ui/page/hosting_page.dart +++ b/lib/src/ui/page/hosting_page.dart @@ -7,11 +7,8 @@ import 'package:reboot_launcher/src/ui/widget/home/version_selector.dart'; import 'package:reboot_launcher/src/ui/widget/shared/setting_tile.dart'; import '../../model/update_status.dart'; -import '../../util/reboot.dart'; -import '../controller/update_controller.dart'; import 'browse_page.dart'; - class HostingPage extends StatefulWidget { final GlobalKey navigatorKey; final RxBool nestedNavigation; @@ -34,6 +31,20 @@ class _HostingPageState extends State with AutomaticKeepAliveClient return Obx(() => !_settingsController.autoUpdate() || _hostingController.updateStatus().isDone() ? _body : _updateScreen); } + Widget get _updateScreen => const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + ProgressRing(), + SizedBox(height: 16.0), + Text("Updating Reboot DLL...") + ], + ), + ], + ); + Widget get _body => Navigator( key: widget.navigatorKey, initialRoute: "home", @@ -49,129 +60,140 @@ class _HostingPageState extends State with AutomaticKeepAliveClient Widget _createScreen(String? name) { switch(name){ case "home": - return _homeScreen; + return _HostPage(widget.navigatorKey, widget.nestedNavigation); case "browse": return const BrowsePage(); default: throw Exception("Unknown page: $name"); } } +} - Widget get _homeScreen => Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.max, - children: [ - Obx(() => SizedBox( - width: double.infinity, - child: _hostingController.updateStatus.value == UpdateStatus.error ? _updateError :_rebootGuiInfo, - )), - const SizedBox( - height: 16.0 - ), - SettingTile( - title: "Game Server", - subtitle: "Provide basic information about your server", - expandedContentSpacing: 0, - expandedContent: [ - SettingTile( - title: "Name", - subtitle: "The name of your game server", - isChild: true, - content: TextFormBox( - placeholder: "Name", - controller: _hostingController.name - ) +class _HostPage extends StatefulWidget { + final GlobalKey navigatorKey; + final RxBool nestedNavigation; + const _HostPage(this.navigatorKey, this.nestedNavigation, {Key? key}) : super(key: key); + + @override + State<_HostPage> createState() => _HostPageState(); +} + +class _HostPageState extends State<_HostPage> with AutomaticKeepAliveClientMixin { + final HostingController _hostingController = Get.find(); + + @override + bool get wantKeepAlive => true; + + @override + Widget build(BuildContext context) { + super.build(context); + return Column( + children: [ + Expanded( + child: ListView( + children: [ + Obx(() => SizedBox( + width: double.infinity, + child: _hostingController.updateStatus.value == UpdateStatus.error ? _updateError : _rebootGuiInfo, + )), + const SizedBox( + height: 16.0 + ), + SettingTile( + title: "Game Server", + subtitle: "Provide basic information about your server", + expandedContentSpacing: 0, + expandedContent: [ + SettingTile( + title: "Name", + subtitle: "The name of your game server", + isChild: true, + content: TextFormBox( + placeholder: "Name", + controller: _hostingController.name + ) + ), + SettingTile( + title: "Description", + subtitle: "The description of your game server", + isChild: true, + content: TextFormBox( + placeholder: "Description", + controller: _hostingController.description + ) + ), + SettingTile( + title: "Discoverable", + subtitle: "Make your server available to other players on the server browser", + isChild: true, + contentWidth: null, + content: Obx(() => ToggleSwitch( + checked: _hostingController.discoverable(), + onChanged: (value) => _hostingController.discoverable.value = value + )) + ), + ], + ), + const SizedBox( + height: 16.0, + ), + SettingTile( + title: "Version", + subtitle: "Select the version of Fortnite you want to host", + content: const VersionSelector(), + expandedContent: [ + SettingTile( + title: "Add a version from this PC's local storage", + subtitle: "Versions coming from your local disk are not guaranteed to work", + content: Button( + onPressed: () => VersionSelector.openAddDialog(context), + child: const Text("Add build"), + ), + isChild: true + ), + SettingTile( + title: "Download any version from the cloud", + subtitle: "A curated list of supported versions by Project Reboot", + content: Button( + onPressed: () => VersionSelector.openDownloadDialog(context), + child: const Text("Download"), + ), + isChild: true + ) + ] + ), + const SizedBox( + height: 16.0, + ), + SettingTile( + title: "Browse available servers", + subtitle: "See a list of other game servers that are being hosted", + content: Button( + onPressed: () { + widget.navigatorKey.currentState?.pushNamed('browse'); + widget.nestedNavigation.value = true; + }, + child: const Text("Browse") + ) + ), + ], ), - SettingTile( - title: "Description", - subtitle: "The description of your game server", - isChild: true, - content: TextFormBox( - placeholder: "Description", - controller: _hostingController.description - ) - ), - SettingTile( - title: "Discoverable", - subtitle: "Make your server available to other players on the server browser", - isChild: true, - contentWidth: null, - content: Obx(() => ToggleSwitch( - checked: _hostingController.discoverable(), - onChanged: (value) => _hostingController.discoverable.value = value - )) - ), - ], - ), - const SizedBox( - height: 16.0, - ), - SettingTile( - title: "Version", - subtitle: "Select the version of Fortnite you want to host", - content: const VersionSelector(), - expandedContent: [ - SettingTile( - title: "Add a version from this PC's local storage", - subtitle: "Versions coming from your local disk are not guaranteed to work", - content: Button( - onPressed: () => VersionSelector.openAddDialog(context), - child: const Text("Add build"), - ), - isChild: true - ), - SettingTile( - title: "Download any version from the cloud", - subtitle: "A curated list of supported versions by Project Reboot", - content: Button( - onPressed: () => VersionSelector.openDownloadDialog(context), - child: const Text("Download"), - ), - isChild: true - ) - ] - ), - const SizedBox( - height: 16.0, - ), - SettingTile( - title: "Browse available servers", - subtitle: "See a list of other game servers that are being hosted", - content: Button( - onPressed: () { - widget.navigatorKey.currentState?.pushNamed('browse'); - widget.nestedNavigation.value = true; - }, - child: const Text("Browse") - ) - ), - const Expanded(child: SizedBox()), - const LaunchButton( - host: true - ) - ], - ); + ), + const SizedBox( + height: 8.0, + ), + const LaunchButton( + host: true + ) + ], + ); + } InfoBar get _rebootGuiInfo => const InfoBar( title: Text("A window will pop up after the game server is started to modify its in-game settings"), severity: InfoBarSeverity.info ); - - Widget get _updateScreen => const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - ProgressRing(), - SizedBox(height: 16.0), - Text("Updating Reboot DLL...") - ], - ), - ], - ); - Widget get _updateError => MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( diff --git a/lib/src/ui/page/launcher_page.dart b/lib/src/ui/page/launcher_page.dart index 7e77616..0c9ef04 100644 --- a/lib/src/ui/page/launcher_page.dart +++ b/lib/src/ui/page/launcher_page.dart @@ -69,108 +69,115 @@ class _GamePageState extends State<_GamePage> { @override Widget build(BuildContext context) => Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - SettingTile( - title: "Credentials", - subtitle: "Your in-game login credentials", - expandedContentSpacing: 0, - expandedContent: [ - SettingTile( - title: "Username", - subtitle: "The username that other players will see when you are in game", - isChild: true, - content: TextFormBox( - placeholder: "Username", - controller: _gameController.username, - autovalidateMode: AutovalidateMode.always - ), - ), - SettingTile( - title: "Password", - subtitle: "The password of your account, only used if the backend requires it", - isChild: true, - content: Obx(() => TextFormBox( - placeholder: "Password", - controller: _gameController.password, - autovalidateMode: AutovalidateMode.always, - obscureText: !_gameController.showPassword.value, - enableSuggestions: false, - autocorrect: false, - onChanged: (text) => _showPasswordTrailing.value = text.isNotEmpty, - suffix: Button( - onPressed: () => _gameController.showPassword.value = !_gameController.showPassword.value, - style: ButtonStyle( - shape: ButtonState.all(const CircleBorder()), - backgroundColor: ButtonState.all(Colors.transparent) - ), - child: Icon( - _gameController.showPassword.value ? Icons.visibility_off : Icons.visibility, - color: _showPasswordTrailing.value ? null : Colors.transparent + Expanded( + child: ListView( + children: [ + SettingTile( + title: "Credentials", + subtitle: "Your in-game login credentials", + expandedContentSpacing: 0, + expandedContent: [ + SettingTile( + title: "Username", + subtitle: "The username that other players will see when you are in game", + isChild: true, + content: TextFormBox( + placeholder: "Username", + controller: _gameController.username, + autovalidateMode: AutovalidateMode.always ), + ), + SettingTile( + title: "Password", + subtitle: "The password of your account, only used if the backend requires it", + isChild: true, + content: Obx(() => TextFormBox( + placeholder: "Password", + controller: _gameController.password, + autovalidateMode: AutovalidateMode.always, + obscureText: !_gameController.showPassword.value, + enableSuggestions: false, + autocorrect: false, + onChanged: (text) => _showPasswordTrailing.value = text.isNotEmpty, + suffix: Button( + onPressed: () => _gameController.showPassword.value = !_gameController.showPassword.value, + style: ButtonStyle( + shape: ButtonState.all(const CircleBorder()), + backgroundColor: ButtonState.all(Colors.transparent) + ), + child: Icon( + _gameController.showPassword.value ? Icons.visibility_off : Icons.visibility, + color: _showPasswordTrailing.value ? null : Colors.transparent + ), + ) + )) ) - )) - ) - ], - ), - const SizedBox( - height: 16.0, - ), - SettingTile( - title: "Matchmaking host", - subtitle: "Enter the IP address of the game server hosting the match", - content: TextFormBox( - placeholder: "IP:PORT", - controller: _settingsController.matchmakingIp, - validator: checkMatchmaking, - autovalidateMode: AutovalidateMode.always - ), - expandedContent: [ - SettingTile( - title: "Browse available servers", - subtitle: "Discover new game servers that fit your play-style", - content: Button( - onPressed: () { - widget.navigatorKey.currentState?.pushNamed('browse'); - widget.nestedNavigation.value = true; - }, - child: const Text("Browse") + ], ), - isChild: true - ) - ] - ), - const SizedBox( - height: 16.0, - ), - SettingTile( - title: "Version", - subtitle: "Select the version of Fortnite you want to play", - content: const VersionSelector(), - expandedContent: [ - SettingTile( - title: "Add a version from this PC's local storage", - subtitle: "Versions coming from your local disk are not guaranteed to work", - content: Button( - onPressed: () => VersionSelector.openAddDialog(context), - child: const Text("Add build"), - ), - isChild: true + const SizedBox( + height: 16.0, ), SettingTile( - title: "Download any version from the cloud", - subtitle: "A curated list of supported versions by Project Reboot", - content: Button( - onPressed: () => VersionSelector.openDownloadDialog(context), - child: const Text("Download"), - ), - isChild: true + title: "Matchmaking host", + subtitle: "Enter the IP address of the game server hosting the match", + content: TextFormBox( + placeholder: "IP:PORT", + controller: _settingsController.matchmakingIp, + validator: checkMatchmaking, + autovalidateMode: AutovalidateMode.always + ), + expandedContent: [ + SettingTile( + title: "Browse available servers", + subtitle: "Discover new game servers that fit your play-style", + content: Button( + onPressed: () { + widget.navigatorKey.currentState?.pushNamed('browse'); + widget.nestedNavigation.value = true; + }, + child: const Text("Browse") + ), + isChild: true + ) + ] + ), + const SizedBox( + height: 16.0, + ), + SettingTile( + title: "Version", + subtitle: "Select the version of Fortnite you want to play", + content: const VersionSelector(), + expandedContent: [ + SettingTile( + title: "Add a version from this PC's local storage", + subtitle: "Versions coming from your local disk are not guaranteed to work", + content: Button( + onPressed: () => VersionSelector.openAddDialog(context), + child: const Text("Add build"), + ), + isChild: true + ), + SettingTile( + title: "Download any version from the cloud", + subtitle: "A curated list of supported versions by Project Reboot", + content: Button( + onPressed: () => VersionSelector.openDownloadDialog(context), + child: const Text("Download"), + ), + isChild: true + ) + ] ) - ] + ], + ), + ), + const SizedBox( + height: 8.0, ), - const Expanded(child: SizedBox()), const LaunchButton( - host: false + host: false ) ], ); diff --git a/lib/src/ui/page/server_page.dart b/lib/src/ui/page/server_page.dart index 2544372..890008f 100644 --- a/lib/src/ui/page/server_page.dart +++ b/lib/src/ui/page/server_page.dart @@ -29,73 +29,80 @@ class _ServerPageState extends State with AutomaticKeepAliveClientMi Widget build(BuildContext context) { super.build(context); return Obx(() => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox( - width: double.infinity, - child: InfoBar( - title: Text("The backend server handles authentication and parties, not game hosting"), - severity: InfoBarSeverity.info - ), + children: [ + Expanded( + child: ListView( + children: [ + const SizedBox( + width: double.infinity, + child: InfoBar( + title: Text("The backend server handles authentication and parties, not game hosting"), + severity: InfoBarSeverity.info + ), + ), + const SizedBox( + height: 16.0, + ), + SettingTile( + title: "Host", + subtitle: "Enter the host of the backend server", + content: TextFormBox( + placeholder: "Host", + controller: _serverController.host, + enabled: _isRemote + ) + ), + const SizedBox( + height: 16.0, + ), + SettingTile( + title: "Port", + subtitle: "Enter the port of the backend server", + content: TextFormBox( + placeholder: "Port", + controller: _serverController.port, + enabled: _isRemote + ) + ), + const SizedBox( + height: 16.0, + ), + SettingTile( + title: "Type", + subtitle: "Select the type of backend to use", + content: ServerTypeSelector() + ), + const SizedBox( + height: 16.0, + ), + SettingTile( + title: "Detached", + subtitle: "Choose whether the backend should be started as a separate process, useful for debugging", + contentWidth: null, + content: Obx(() => ToggleSwitch( + checked: _serverController.detached(), + onChanged: (value) => _serverController.detached.value = value + )) + ), + const SizedBox( + height: 16.0, + ), + SettingTile( + title: "Server files", + subtitle: "The location where the backend is stored", + content: Button( + onPressed: () => launchUrl(serverDirectory.uri), + child: const Text("Open") + ) + ), + ] ), - const SizedBox( - height: 16.0, - ), - SettingTile( - title: "Host", - subtitle: "Enter the host of the backend server", - content: TextFormBox( - placeholder: "Host", - controller: _serverController.host, - enabled: _isRemote - ) - ), - const SizedBox( - height: 16.0, - ), - SettingTile( - title: "Port", - subtitle: "Enter the port of the backend server", - content: TextFormBox( - placeholder: "Port", - controller: _serverController.port, - enabled: _isRemote - ) - ), - const SizedBox( - height: 16.0, - ), - SettingTile( - title: "Type", - subtitle: "Select the type of backend to use", - content: ServerTypeSelector() - ), - const SizedBox( - height: 16.0, - ), - SettingTile( - title: "Detached", - subtitle: "Choose whether the backend should be started as a separate process, useful for debugging", - contentWidth: null, - content: Obx(() => ToggleSwitch( - checked: _serverController.detached(), - onChanged: (value) => _serverController.detached.value = value - )) - ), - const SizedBox( - height: 16.0, - ), - SettingTile( - title: "Server files", - subtitle: "The location where the backend is stored", - content: Button( - onPressed: () => launchUrl(serverDirectory.uri), - child: const Text("Open") - ) - ), - const Expanded(child: SizedBox()), - const ServerButton() - ] + ), + const SizedBox( + height: 8.0, + ), + ServerButton() + ], )); } diff --git a/lib/src/ui/page/settings_page.dart b/lib/src/ui/page/settings_page.dart index a56119c..aef9295 100644 --- a/lib/src/ui/page/settings_page.dart +++ b/lib/src/ui/page/settings_page.dart @@ -33,8 +33,7 @@ class _SettingsPageState extends State with AutomaticKeepAliveClie @override Widget build(BuildContext context) { super.build(context); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, + return ListView( children: [ SettingTile( title: "File settings", diff --git a/lib/src/ui/widget/home/launch_button.dart b/lib/src/ui/widget/home/launch_button.dart index ec1cf61..e8b9403 100644 --- a/lib/src/ui/widget/home/launch_button.dart +++ b/lib/src/ui/widget/home/launch_button.dart @@ -185,7 +185,7 @@ class _LaunchButtonState extends State { } Future _createGameProcess(String gamePath, bool host) async { - var gameArgs = createRebootArgs(_safeUsername, host, _gameController.customLaunchArgs.text); + var gameArgs = createRebootArgs(_safeUsername, _gameController.password.text, host, _gameController.customLaunchArgs.text); var gameProcess = await Process.start(gamePath, gameArgs); gameProcess ..exitCode.then((_) => _onEnd()) diff --git a/lib/src/ui/widget/shared/boxed_pane_item.dart b/lib/src/ui/widget/shared/boxed_pane_item.dart new file mode 100644 index 0000000..ba30243 --- /dev/null +++ b/lib/src/ui/widget/shared/boxed_pane_item.dart @@ -0,0 +1,31 @@ +import 'package:fluent_ui/fluent_ui.dart'; + +class SquaredPaneItem extends PaneItem { + SquaredPaneItem({ + super.key, + required super.title, + required super.icon, + required super.body, + }); + + @override + Widget build( + BuildContext context, + bool selected, + VoidCallback? onPressed, { + PaneDisplayMode? displayMode, + bool showTextOnTop = true, + int? itemIndex, + bool? autofocus, + }) { + return Column( + children: [ + SizedBox.square( + dimension: 48, + child: icon + ), + title! + ], + ); + } +} diff --git a/lib/src/util/server.dart b/lib/src/util/server.dart index d98369d..a8cee4e 100644 --- a/lib/src/util/server.dart +++ b/lib/src/util/server.dart @@ -82,9 +82,13 @@ Future resetWinNat() async { await runElevated(binary.path, ""); } -List createRebootArgs(String username, bool host, String additionalArgs) { - username = username.isEmpty ? kDefaultPlayerName : username; - username = host ? "$username${Random().nextInt(1000)}" : username; +List createRebootArgs(String username, String password, bool host, String additionalArgs) { + if(password.isNotEmpty) { + username = username.isEmpty ? kDefaultPlayerName : username; + username = host ? "$username${Random().nextInt(1000)}" : username; + username = '$username@projectreboot.dev'; + } + password = password.isNotEmpty ? password : "Rebooted"; var args = [ "-epicapp=Fortnite", "-epicenv=Prod", @@ -95,8 +99,8 @@ List createRebootArgs(String username, bool host, String additionalArgs) "-fromfl=eac", "-fltoken=3db3ba5dcbd2e16703f3978d", "-caldera=eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJhY2NvdW50X2lkIjoiYmU5ZGE1YzJmYmVhNDQwN2IyZjQwZWJhYWQ4NTlhZDQiLCJnZW5lcmF0ZWQiOjE2Mzg3MTcyNzgsImNhbGRlcmFHdWlkIjoiMzgxMGI4NjMtMmE2NS00NDU3LTliNTgtNGRhYjNiNDgyYTg2IiwiYWNQcm92aWRlciI6IkVhc3lBbnRpQ2hlYXQiLCJub3RlcyI6IiIsImZhbGxiYWNrIjpmYWxzZX0.VAWQB67RTxhiWOxx7DBjnzDnXyyEnX7OljJm-j2d88G_WgwQ9wrE6lwMEHZHjBd1ISJdUO1UVUqkfLdU5nofBQ", - "-AUTH_LOGIN=$username@projectreboot.dev", - "-AUTH_PASSWORD=Rebooted", + "-AUTH_LOGIN=$username", + "-AUTH_PASSWORD=${password.isNotEmpty ? password : "Rebooted"}", "-AUTH_TYPE=epic" ]; diff --git a/pubspec.yaml b/pubspec.yaml index a8383f9..c13fa50 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -42,6 +42,7 @@ dependencies: uuid: ^3.0.6 supabase_flutter: ^1.10.0 supabase: ^1.9.1 + fluentui_system_icons: ^1.1.202 dev_dependencies: flutter_test: