diff --git a/lib/backend/modules/webapp.dart b/lib/backend/modules/webapp.dart new file mode 100644 index 0000000..b1307f5 --- /dev/null +++ b/lib/backend/modules/webapp.dart @@ -0,0 +1,45 @@ +import '../api.dart'; +import '../../core/protocol/opcode_map.dart'; + +class WebAppLaunch { + final String url; + + const WebAppLaunch({required this.url}); +} + +class WebAppModule { + static const int sferumBotId = 2340319; + + final Api _api; + + WebAppModule(this._api); + + Future fetchLaunch(int botId) async { + if (_api.state != SessionState.online) { + throw const WebAppUnavailable('Нет соединения с сервером'); + } + final packet = await _api.sendRequest(Opcode.webAppInitData, { + 'botId': botId, + }); + if (!packet.isOk) { + throw const WebAppUnavailable('Не удалось открыть мини-приложение'); + } + final data = packet.payload; + final url = (data is Map) ? data['url'] as String? : null; + if (url == null || url.isEmpty) { + throw const WebAppUnavailable('Сервер не вернул адрес приложения'); + } + return WebAppLaunch(url: url); + } + + Future fetchSferum() => fetchLaunch(sferumBotId); +} + +class WebAppUnavailable implements Exception { + final String message; + + const WebAppUnavailable(this.message); + + @override + String toString() => message; +} diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index dd43344..84ffc8e 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -15,6 +15,7 @@ import '../../widgets/komet_avatar.dart'; import '../../widgets/sheet_helpers.dart'; import '../auth/login_screen.dart'; import '../auth/proxy_settings_sheet.dart'; +import '../webapp/web_app_screen.dart'; import 'cloud_storage_screen.dart'; import 'customization_screen.dart'; import 'performance_screen.dart'; @@ -252,9 +253,20 @@ class _SettingsTabState extends State { icon: Symbols.badge, label: 'Цифровой ID', ), - const _SettingsItem( + _SettingsItem( icon: Symbols.language, label: 'Войти в Сферум', + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => WebAppScreen( + title: 'Сферум', + loader: () => webAppModule.fetchSferum(), + ), + ), + ); + }, ), _SettingsItem( icon: Symbols.info, diff --git a/lib/frontend/screens/webapp/web_app_screen.dart b/lib/frontend/screens/webapp/web_app_screen.dart new file mode 100644 index 0000000..7d17af9 --- /dev/null +++ b/lib/frontend/screens/webapp/web_app_screen.dart @@ -0,0 +1,167 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../../../backend/modules/webapp.dart'; + +class WebAppScreen extends StatefulWidget { + final String title; + final Future Function() loader; + + const WebAppScreen({ + super.key, + required this.title, + required this.loader, + }); + + @override + State createState() => _WebAppScreenState(); +} + +class _WebAppScreenState extends State { + InAppWebViewController? _controller; + WebAppLaunch? _launch; + String? _loadError; + double _progress = 0; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _loadError = null; + _launch = null; + }); + try { + final launch = await widget.loader(); + if (!mounted) return; + setState(() => _launch = launch); + } catch (e) { + if (!mounted) return; + setState(() => _loadError = e.toString()); + } + } + + Future _handleBack() async { + final controller = _controller; + if (controller != null && await controller.canGoBack()) { + await controller.goBack(); + return false; + } + return true; + } + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, result) async { + if (didPop) return; + final navigator = Navigator.of(context); + if (await _handleBack()) navigator.pop(); + }, + child: Scaffold( + backgroundColor: cs.surface, + appBar: AppBar( + backgroundColor: cs.surface, + surfaceTintColor: Colors.transparent, + title: Text(widget.title), + leading: IconButton( + icon: const Icon(Symbols.close), + onPressed: () => Navigator.of(context).maybePop(), + ), + actions: [ + IconButton( + icon: const Icon(Symbols.refresh), + onPressed: _launch == null + ? null + : () => _controller?.reload(), + ), + ], + bottom: _progress > 0 && _progress < 1 + ? PreferredSize( + preferredSize: const Size.fromHeight(2), + child: LinearProgressIndicator( + value: _progress, + minHeight: 2, + backgroundColor: Colors.transparent, + ), + ) + : null, + ), + body: _buildBody(cs), + ), + ); + } + + Widget _buildBody(ColorScheme cs) { + if (_loadError != null) { + return _ErrorView(message: _loadError!, onRetry: _load); + } + final launch = _launch; + if (launch == null) { + return const Center(child: CircularProgressIndicator()); + } + return InAppWebView( + initialUrlRequest: URLRequest(url: WebUri(launch.url)), + initialSettings: InAppWebViewSettings( + javaScriptEnabled: true, + domStorageEnabled: true, + thirdPartyCookiesEnabled: true, + supportZoom: false, + transparentBackground: true, + mediaPlaybackRequiresUserGesture: false, + useHybridComposition: true, + ), + onWebViewCreated: (controller) => _controller = controller, + onProgressChanged: (controller, progress) { + if (!mounted) return; + setState(() => _progress = progress / 100); + }, + onReceivedError: (controller, request, error) { + if (!mounted) return; + if (request.isForMainFrame ?? false) { + setState(() => _loadError = error.description); + } + }, + ); + } +} + +class _ErrorView extends StatelessWidget { + final String message; + final VoidCallback onRetry; + + const _ErrorView({required this.message, required this.onRetry}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Symbols.cloud_off, size: 48, color: cs.onSurfaceVariant), + const SizedBox(height: 16), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle(color: cs.onSurfaceVariant, fontSize: 14), + ), + const SizedBox(height: 24), + FilledButton( + onPressed: onRetry, + child: const Text('Повторить'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index a1b476e..5605d12 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -30,6 +30,7 @@ import 'backend/modules/contacts.dart'; import 'backend/modules/file_uploader.dart'; import 'backend/modules/messages.dart'; import 'backend/modules/polls.dart'; +import 'backend/modules/webapp.dart'; import 'core/push/push_service.dart'; import 'core/storage/app_database.dart'; import 'core/transport/tls_config.dart'; @@ -47,6 +48,7 @@ final api = Api(); final accountModule = AccountModule(api); final messagesModule = MessagesModule(api); final pollsModule = PollsModule(api); +final webAppModule = WebAppModule(api); final fileUploader = FileUploader(api: api, messages: messagesModule); final RouteObserver> appRouteObserver = RouteObserver>(); diff --git a/pubspec.lock b/pubspec.lock index 9bf17e2..5bb08d1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -326,6 +326,70 @@ packages: url: "https://pub.dev" source: hosted version: "3.4.1" + flutter_inappwebview: + dependency: "direct main" + description: + name: flutter_inappwebview + sha256: "80092d13d3e29b6227e25b67973c67c7210bd5e35c4b747ca908e31eb71a46d5" + url: "https://pub.dev" + source: hosted + version: "6.1.5" + flutter_inappwebview_android: + dependency: transitive + description: + name: flutter_inappwebview_android + sha256: "62557c15a5c2db5d195cb3892aab74fcaec266d7b86d59a6f0027abd672cddba" + url: "https://pub.dev" + source: hosted + version: "1.1.3" + flutter_inappwebview_internal_annotations: + dependency: transitive + description: + name: flutter_inappwebview_internal_annotations + sha256: e30fba942e3debea7b7e6cdd4f0f59ce89dd403a9865193e3221293b6d1544c6 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + flutter_inappwebview_ios: + dependency: transitive + description: + name: flutter_inappwebview_ios + sha256: "5818cf9b26cf0cbb0f62ff50772217d41ea8d3d9cc00279c45f8aabaa1b4025d" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_macos: + dependency: transitive + description: + name: flutter_inappwebview_macos + sha256: c1fbb86af1a3738e3541364d7d1866315ffb0468a1a77e34198c9be571287da1 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_platform_interface: + dependency: transitive + description: + name: flutter_inappwebview_platform_interface + sha256: cf5323e194096b6ede7a1ca808c3e0a078e4b33cc3f6338977d75b4024ba2500 + url: "https://pub.dev" + source: hosted + version: "1.3.0+1" + flutter_inappwebview_web: + dependency: transitive + description: + name: flutter_inappwebview_web + sha256: "55f89c83b0a0d3b7893306b3bb545ba4770a4df018204917148ebb42dc14a598" + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_inappwebview_windows: + dependency: transitive + description: + name: flutter_inappwebview_windows + sha256: "8b4d3a46078a2cdc636c4a3d10d10f2a16882f6be607962dbfff8874d1642055" + url: "https://pub.dev" + source: hosted + version: "0.6.0" flutter_launcher_icons: dependency: "direct dev" description: diff --git a/pubspec.yaml b/pubspec.yaml index b497787..adb1786 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -64,6 +64,7 @@ dependencies: firebase_core: ^4.1.1 firebase_messaging: ^16.0.2 flutter_local_notifications: ^21.0.0 + flutter_inappwebview: ^6.1.5 dev_dependencies: flutter_test: