diff --git a/lib/frontend/screens/calls/calls_tab.dart b/lib/frontend/screens/calls/calls_tab.dart index 8a9edd2..b808a7c 100644 --- a/lib/frontend/screens/calls/calls_tab.dart +++ b/lib/frontend/screens/calls/calls_tab.dart @@ -9,6 +9,7 @@ import '../../../core/storage/app_database.dart'; import '../../../core/utils/format.dart'; import '../../../backend/modules/calls.dart'; import '../../widgets/komet_avatar.dart'; +import '../../widgets/connection_status.dart'; class CallsTab extends StatefulWidget { const CallsTab({super.key}); @@ -260,14 +261,20 @@ class _CallsTabState extends State { children: [ Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), - child: Text( - 'Звонки', - style: TextStyle( - color: cs.onSurface, - fontSize: 24, - fontWeight: FontWeight.w700, - fontFamily: 'Outfit', - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Звонки', + style: TextStyle( + color: cs.onSurface, + fontSize: 24, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + const ConnectionStatusLine(), + ], ), ), InkWell( diff --git a/lib/frontend/screens/chats/chat_info_screen.dart b/lib/frontend/screens/chats/chat_info_screen.dart index aedece3..42de619 100644 --- a/lib/frontend/screens/chats/chat_info_screen.dart +++ b/lib/frontend/screens/chats/chat_info_screen.dart @@ -6,6 +6,7 @@ import '../../../backend/modules/messages.dart' show ContactCache; import '../../../core/cache/info_cache.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/utils/format.dart'; +import '../../widgets/connection_status.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/komet_avatar.dart'; @@ -190,6 +191,8 @@ class _ChatInfoScreenState extends State { return Scaffold( backgroundColor: bg, + floatingActionButtonLocation: FloatingActionButtonLocation.startFloat, + floatingActionButton: const ConnectionSpinner(), body: SafeArea( child: _isLoading ? _buildShimmer(cs) : _buildScrollBody(cs), ), diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index c319af0..26b7353 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -39,6 +39,7 @@ import '../../../core/config/komet_settings.dart'; import '../../../models/attachment.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/online_dot.dart'; +import '../../widgets/connection_status.dart'; import '../../widgets/message_bubble.dart'; import '../../widgets/theme_reveal.dart'; import '../../widgets/message_actions_overlay.dart'; @@ -110,6 +111,7 @@ class _ChatScreenState extends State StreamSubscription? _uploadSub; StreamSubscription? _pushSub; StreamSubscription? _messageEventSub; + StreamSubscription? _connSub; final Map?>> _reactionNotifiers = {}; final Map>> _photoUploadProgress = {}; @@ -209,6 +211,10 @@ class _ChatScreenState extends State _messageEventSub = ChatsModule.messageEvents .where((e) => e.chatId == widget.chatId) .listen(_onMessageEvent); + _connSub = api.stateStream.listen((_) { + if (mounted) _recomputeHeaderStatus(); + }); + debugForceOffline.addListener(_recomputeHeaderStatus); PresenceFetch.revision.addListener(_onPresenceChanged); _floatingDateAnimController = AnimationController( vsync: this, @@ -544,6 +550,8 @@ class _ChatScreenState extends State _uploadSub?.cancel(); _pushSub?.cancel(); _messageEventSub?.cancel(); + _connSub?.cancel(); + debugForceOffline.removeListener(_recomputeHeaderStatus); for (final n in _reactionNotifiers.values) { n.dispose(); } @@ -1360,6 +1368,8 @@ class _ChatScreenState extends State } String _headerStatus() { + final conn = connectionStatusLabel(api.state); + if (conn != null) return conn; if (_typingUserIds.isNotEmpty) return 'Печатает...'; if (widget.chatType == 'CHAT') { final count = _participantsCount ?? chat?.participants.length ?? 0; diff --git a/lib/frontend/screens/contacts/contact_profile_screen.dart b/lib/frontend/screens/contacts/contact_profile_screen.dart index 952ca49..5f25161 100644 --- a/lib/frontend/screens/contacts/contact_profile_screen.dart +++ b/lib/frontend/screens/contacts/contact_profile_screen.dart @@ -8,6 +8,7 @@ import '../../../core/utils/format.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/komet_avatar.dart'; +import '../../widgets/connection_status.dart'; import '../../widgets/swipe_route.dart'; import '../chats/chat_screen.dart'; @@ -127,6 +128,8 @@ class _ContactProfileScreenState extends State { final cs = Theme.of(context).colorScheme; return Scaffold( backgroundColor: cs.surface, + floatingActionButtonLocation: FloatingActionButtonLocation.startFloat, + floatingActionButton: const ConnectionSpinner(), body: SafeArea( child: _loading ? const Center(child: CircularProgressIndicator()) diff --git a/lib/frontend/screens/contacts/contacts_tab.dart b/lib/frontend/screens/contacts/contacts_tab.dart index 8d43163..98566a5 100644 --- a/lib/frontend/screens/contacts/contacts_tab.dart +++ b/lib/frontend/screens/contacts/contacts_tab.dart @@ -6,6 +6,7 @@ import '../../../core/storage/app_database.dart'; import '../../../backend/modules/contacts.dart'; import '../../../main.dart'; import '../../widgets/komet_avatar.dart'; +import '../../widgets/connection_status.dart'; import '../../widgets/sheet_helpers.dart'; import 'contact_profile_screen.dart'; @@ -168,14 +169,21 @@ class _ContactsTabState extends State { child: Row( children: [ Expanded( - child: Text( - 'Контакты', - style: TextStyle( - color: cs.onSurface, - fontSize: 24, - fontWeight: FontWeight.w700, - fontFamily: 'Outfit', - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Контакты', + style: TextStyle( + color: cs.onSurface, + fontSize: 24, + fontWeight: FontWeight.w700, + fontFamily: 'Outfit', + ), + ), + const ConnectionStatusLine(), + ], ), ), IconButton( diff --git a/lib/frontend/screens/digital_id/digital_id_screen.dart b/lib/frontend/screens/digital_id/digital_id_screen.dart index cbbaf97..4329946 100644 --- a/lib/frontend/screens/digital_id/digital_id_screen.dart +++ b/lib/frontend/screens/digital_id/digital_id_screen.dart @@ -6,6 +6,7 @@ import '../../../backend/modules/webapp.dart'; import '../../../core/utils/webview_support.dart'; import '../../../main.dart' show digitalIdModule; import '../../../models/digital_id.dart'; +import '../../widgets/connection_status.dart'; import '../../widgets/custom_notification.dart'; import '../webapp/web_app_screen.dart'; @@ -155,6 +156,8 @@ class _DigitalIdScreenState extends State { final cs = Theme.of(context).colorScheme; return Scaffold( backgroundColor: cs.surface, + floatingActionButtonLocation: FloatingActionButtonLocation.startFloat, + floatingActionButton: const ConnectionSpinner(), appBar: AppBar( backgroundColor: cs.surface, surfaceTintColor: Colors.transparent, diff --git a/lib/frontend/screens/digital_id/digital_id_web_screen.dart b/lib/frontend/screens/digital_id/digital_id_web_screen.dart index 6f0c95e..fd7e3ac 100644 --- a/lib/frontend/screens/digital_id/digital_id_web_screen.dart +++ b/lib/frontend/screens/digital_id/digital_id_web_screen.dart @@ -7,6 +7,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/webapp.dart'; import '../../../main.dart' show webAppModule, digitalIdModule; +import '../../widgets/connection_status.dart'; import '../../widgets/webview_permission_prompt.dart'; Future resetDigitalIdWebData() async { @@ -218,6 +219,8 @@ class _DigitalIdWebScreenState extends State { }, child: Scaffold( backgroundColor: cs.surface, + floatingActionButtonLocation: FloatingActionButtonLocation.startFloat, + floatingActionButton: const ConnectionSpinner(), appBar: AppBar( backgroundColor: cs.surface, surfaceTintColor: Colors.transparent, diff --git a/lib/frontend/screens/profile/app_icon_screen.dart b/lib/frontend/screens/profile/app_icon_screen.dart index 4faa96c..367a8b1 100644 --- a/lib/frontend/screens/profile/app_icon_screen.dart +++ b/lib/frontend/screens/profile/app_icon_screen.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:m3e_collection/m3e_collection.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../widgets/connection_status.dart'; + import '../../../core/config/app_icon.dart'; import '../../../core/utils/haptics.dart'; import '../../widgets/custom_notification.dart'; @@ -46,7 +47,7 @@ class _AppIconScreenState extends State { final cs = Theme.of(context).colorScheme; return Scaffold( backgroundColor: cs.surface, - appBar: AppBarM3E( + appBar: ConnectionTitleBar( titleText: 'Иконка приложения', backgroundColor: cs.surface, ), diff --git a/lib/frontend/screens/profile/appearance_screen.dart b/lib/frontend/screens/profile/appearance_screen.dart index e5c1d95..beff004 100644 --- a/lib/frontend/screens/profile/appearance_screen.dart +++ b/lib/frontend/screens/profile/appearance_screen.dart @@ -1,9 +1,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:m3e_collection/m3e_collection.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../widgets/connection_status.dart'; + import '../../../core/config/app_bubble_behavior.dart'; import '../../../core/config/app_bubble_shape.dart'; import '../../../core/config/app_pill_gradient.dart'; @@ -86,7 +87,10 @@ class _AppearanceScreenState extends State { return Scaffold( backgroundColor: cs.surface, - appBar: AppBarM3E(titleText: 'Внешний вид', backgroundColor: cs.surface), + appBar: ConnectionTitleBar( + titleText: 'Внешний вид', + backgroundColor: cs.surface, + ), body: SafeArea( top: false, child: ListView( diff --git a/lib/frontend/screens/profile/cloud_storage_screen.dart b/lib/frontend/screens/profile/cloud_storage_screen.dart index aef2967..1b4568a 100644 --- a/lib/frontend/screens/profile/cloud_storage_screen.dart +++ b/lib/frontend/screens/profile/cloud_storage_screen.dart @@ -13,6 +13,7 @@ import '../../../backend/modules/upload_manager.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/utils/format.dart'; import '../../../main.dart'; +import '../../widgets/connection_status.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/sheet_helpers.dart'; @@ -315,7 +316,7 @@ class _CloudStorageScreenState extends State icon: Icon(Symbols.arrow_back, color: cs.onSurface), onPressed: _onBack, ), - title: Text( + title: ConnectionTitleText( 'Облачное хранилище', style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600), ), diff --git a/lib/frontend/screens/profile/customization_screen.dart b/lib/frontend/screens/profile/customization_screen.dart index 7f5bc2d..76e2f66 100644 --- a/lib/frontend/screens/profile/customization_screen.dart +++ b/lib/frontend/screens/profile/customization_screen.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:m3e_collection/m3e_collection.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../widgets/connection_status.dart'; + import '../../../core/utils/haptics.dart'; import '../../widgets/glossy_pill.dart'; import 'app_icon_screen.dart'; @@ -80,7 +81,7 @@ class CustomizationScreen extends StatelessWidget { return Scaffold( backgroundColor: cs.surface, - appBar: AppBarM3E( + appBar: ConnectionTitleBar( titleText: 'Кастомизация', backgroundColor: cs.surface, ), diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index ecef538..b20c310 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -22,6 +22,7 @@ import '../../widgets/sheet_helpers.dart'; import '../../widgets/login_success_screen.dart'; import '../calls/call_screen.dart'; import '../../../core/calls/call_controller.dart'; +import '../../widgets/connection_status.dart'; import '../digital_id/digital_id_web_screen.dart'; class DebugMenuScreen extends StatefulWidget { @@ -205,6 +206,8 @@ class _DebugMenuScreenState extends State { return Scaffold( backgroundColor: cs.surface, + floatingActionButtonLocation: FloatingActionButtonLocation.startFloat, + floatingActionButton: const ConnectionSpinner(), body: SafeArea( bottom: false, child: CustomScrollView( @@ -368,6 +371,64 @@ class _DebugMenuScreenState extends State { ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), + child: ValueListenableBuilder( + valueListenable: debugForceOffline, + builder: (context, offline, _) { + return GlossyPill( + color: cs.surfaceContainerHigh, + borderRadius: BorderRadius.circular(20), + depth: 6, + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 17, + ), + child: Row( + children: [ + Icon( + Symbols.wifi_off, + color: cs.onSurfaceVariant, + size: 22, + weight: 400, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Офлайн (тест)', + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + 'Показать индикаторы соединения во всех ' + 'экранах, не разрывая реальную сессию', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 13, + ), + ), + ], + ), + ), + Switch( + value: offline, + onChanged: (v) => debugForceOffline.value = v, + ), + ], + ), + ); + }, + ), + ), + ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), diff --git a/lib/frontend/screens/profile/devices_screen.dart b/lib/frontend/screens/profile/devices_screen.dart index 1363d21..982128d 100644 --- a/lib/frontend/screens/profile/devices_screen.dart +++ b/lib/frontend/screens/profile/devices_screen.dart @@ -10,6 +10,7 @@ import '../../../core/utils/format.dart'; import '../../../main.dart' show accountModule; import '../../../backend/modules/account.dart' show SessionInfo; import '../../widgets/custom_notification.dart'; +import '../../widgets/connection_status.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/sheet_helpers.dart'; import 'web_qr_scan_screen.dart'; @@ -327,7 +328,7 @@ class _DevicesScreenState extends State icon: const Icon(Symbols.chevron_left, size: 28), onPressed: () => Navigator.pop(context), ), - title: Text( + title: ConnectionTitleText( 'Устройства', style: GoogleFonts.outfit( fontSize: 20, diff --git a/lib/frontend/screens/profile/edit_profile_screen.dart b/lib/frontend/screens/profile/edit_profile_screen.dart index a1da0b7..5aa4075 100644 --- a/lib/frontend/screens/profile/edit_profile_screen.dart +++ b/lib/frontend/screens/profile/edit_profile_screen.dart @@ -5,6 +5,7 @@ import '../../../core/storage/app_database.dart'; import '../../../core/utils/image_utils.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart' show accountModule, fileUploader, KometApp; +import '../../widgets/connection_status.dart'; import '../../widgets/custom_notification.dart'; class EditProfileScreen extends StatefulWidget { @@ -170,7 +171,7 @@ class _EditProfileScreenState extends State { icon: Icon(Symbols.arrow_back, color: cs.onSurface), onPressed: () => Navigator.pop(context), ), - title: Text( + title: ConnectionTitleText( l10n?.editProfileTitle ?? 'Edit Profile', style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600), ), diff --git a/lib/frontend/screens/profile/font_settings_screen.dart b/lib/frontend/screens/profile/font_settings_screen.dart index 8c3f878..84d908e 100644 --- a/lib/frontend/screens/profile/font_settings_screen.dart +++ b/lib/frontend/screens/profile/font_settings_screen.dart @@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../core/config/app_fonts.dart'; import '../../../core/utils/haptics.dart'; import '../../../main.dart'; +import '../../widgets/connection_status.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/glossy_pill.dart'; @@ -139,7 +140,10 @@ class _FontSettingsScreenState extends State { return Scaffold( backgroundColor: cs.surface, - appBar: AppBarM3E(titleText: 'Шрифты', backgroundColor: cs.surface), + appBar: ConnectionTitleBar( + titleText: 'Шрифты', + backgroundColor: cs.surface, + ), body: SafeArea( top: false, child: ListView( diff --git a/lib/frontend/screens/profile/info_screen.dart b/lib/frontend/screens/profile/info_screen.dart index 405ec8b..518a3aa 100644 --- a/lib/frontend/screens/profile/info_screen.dart +++ b/lib/frontend/screens/profile/info_screen.dart @@ -4,6 +4,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../../../core/storage/app_database.dart'; import '../../../core/storage/token_storage.dart'; import '../../../l10n/app_localizations.dart'; +import '../../widgets/connection_status.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/section_header.dart'; @@ -60,7 +61,7 @@ class _InfoScreenState extends State { icon: Icon(Symbols.arrow_back, color: cs.onSurface), onPressed: () => Navigator.pop(context), ), - title: Text( + title: ConnectionTitleText( l10n?.infoTitle ?? 'Info', style: TextStyle(color: cs.onSurface, fontWeight: FontWeight.w600), ), diff --git a/lib/frontend/screens/profile/komet_settings_screen.dart b/lib/frontend/screens/profile/komet_settings_screen.dart index 947d1b6..e6158f0 100644 --- a/lib/frontend/screens/profile/komet_settings_screen.dart +++ b/lib/frontend/screens/profile/komet_settings_screen.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:m3e_collection/m3e_collection.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../widgets/connection_status.dart'; + import '../../../core/config/komet_settings.dart'; import '../../../main.dart'; import '../../widgets/glossy_pill.dart'; @@ -16,7 +17,7 @@ class KometSettingsScreen extends StatelessWidget { return Scaffold( backgroundColor: cs.surface, - appBar: AppBarM3E(titleText: 'Komet', backgroundColor: cs.surface), + appBar: ConnectionTitleBar(titleText: 'Komet', backgroundColor: cs.surface), body: SafeArea( top: false, child: ListView( diff --git a/lib/frontend/screens/profile/message_actions_screen.dart b/lib/frontend/screens/profile/message_actions_screen.dart index 843ab9a..65f04b3 100644 --- a/lib/frontend/screens/profile/message_actions_screen.dart +++ b/lib/frontend/screens/profile/message_actions_screen.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:m3e_collection/m3e_collection.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../widgets/connection_status.dart'; + import '../../../core/config/app_message_actions_style.dart'; import '../../../core/utils/haptics.dart'; import '../../widgets/glossy_pill.dart'; @@ -14,7 +15,7 @@ class MessageActionsScreen extends StatelessWidget { final cs = Theme.of(context).colorScheme; return Scaffold( backgroundColor: cs.surface, - appBar: AppBarM3E( + appBar: ConnectionTitleBar( titleText: 'Меню действий', backgroundColor: cs.surface, ), diff --git a/lib/frontend/screens/profile/notifications_screen.dart b/lib/frontend/screens/profile/notifications_screen.dart index 81e0dbb..c211f12 100644 --- a/lib/frontend/screens/profile/notifications_screen.dart +++ b/lib/frontend/screens/profile/notifications_screen.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:m3e_collection/m3e_collection.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../widgets/connection_status.dart'; + import '../../widgets/glossy_pill.dart'; import '../../widgets/section_header.dart'; import '../../widgets/sheet_helpers.dart'; @@ -89,7 +90,10 @@ class _NotificationsScreenState extends State { return Scaffold( backgroundColor: cs.surface, - appBar: AppBarM3E(titleText: 'Уведомления', backgroundColor: cs.surface), + appBar: ConnectionTitleBar( + titleText: 'Уведомления', + backgroundColor: cs.surface, + ), body: SafeArea( top: false, child: ListView( diff --git a/lib/frontend/screens/profile/performance_screen.dart b/lib/frontend/screens/profile/performance_screen.dart index f34a387..7c401fb 100644 --- a/lib/frontend/screens/profile/performance_screen.dart +++ b/lib/frontend/screens/profile/performance_screen.dart @@ -1,5 +1,5 @@ import 'package:flutter/material.dart'; -import 'package:m3e_collection/m3e_collection.dart'; +import '../../widgets/connection_status.dart'; import '../../../core/config/app_cache_extent.dart'; import '../../../core/utils/haptics.dart'; @@ -91,7 +91,7 @@ class _PerformanceScreenState extends State { return Scaffold( backgroundColor: cs.surface, - appBar: AppBarM3E( + appBar: ConnectionTitleBar( titleText: 'Производительность', backgroundColor: cs.surface, ), diff --git a/lib/frontend/screens/profile/security_screen.dart b/lib/frontend/screens/profile/security_screen.dart index db4c779..5e4f42c 100644 --- a/lib/frontend/screens/profile/security_screen.dart +++ b/lib/frontend/screens/profile/security_screen.dart @@ -7,6 +7,7 @@ import '../../../backend/modules/account.dart' import '../../../core/storage/app_database.dart'; import '../../widgets/confirm_dialog.dart'; import '../../widgets/custom_notification.dart'; +import '../../widgets/connection_status.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/sheet_helpers.dart'; import 'password_entry_screen.dart'; @@ -197,7 +198,7 @@ class _SecurityScreenState extends State onPressed: () => Navigator.pop(context), ), const SizedBox(width: 4), - Text( + ConnectionTitleText( 'Безопасность', style: TextStyle( color: cs.onSurface, diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 1e1913f..33ea1cd 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -13,6 +13,7 @@ import '../../../core/utils/format.dart'; import '../../../core/utils/haptics.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; +import '../../widgets/connection_status.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/info_action_sheet.dart'; import '../../widgets/komet_avatar.dart'; @@ -565,6 +566,9 @@ class _SettingsTabState extends State { ), onPressed: () {}, ), + const Expanded( + child: ConnectionStatusLine(textAlign: TextAlign.center), + ), IconButton( icon: Icon( Symbols.edit, diff --git a/lib/frontend/screens/profile/spoof_screen.dart b/lib/frontend/screens/profile/spoof_screen.dart index bf173af..25ddb9b 100644 --- a/lib/frontend/screens/profile/spoof_screen.dart +++ b/lib/frontend/screens/profile/spoof_screen.dart @@ -13,6 +13,7 @@ import '../../../core/storage/spoofing_service.dart'; import '../../../core/storage/token_storage.dart'; import '../../../l10n/app_localizations.dart'; import '../../../main.dart'; +import '../../widgets/connection_status.dart'; import '../../widgets/info_action_sheet.dart'; import '../../widgets/section_header.dart'; import '../auth/login_screen.dart'; @@ -438,7 +439,10 @@ class _SpoofScreenState extends State { Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; return Scaffold( - appBar: AppBar(title: Text(l10n.spoofScreenTitle), centerTitle: true), + appBar: AppBar( + title: ConnectionTitleText(l10n.spoofScreenTitle), + centerTitle: true, + ), body: _isLoading ? const Center(child: CircularProgressIndicator()) : SingleChildScrollView( diff --git a/lib/frontend/screens/profile/theme_settings_screen.dart b/lib/frontend/screens/profile/theme_settings_screen.dart index bdc2d62..54cf9a5 100644 --- a/lib/frontend/screens/profile/theme_settings_screen.dart +++ b/lib/frontend/screens/profile/theme_settings_screen.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:m3e_collection/m3e_collection.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../widgets/connection_status.dart'; + import '../../../core/config/app_amoled.dart'; import '../../../core/config/app_theme_mode.dart'; import '../../../core/config/app_theme_schedule.dart'; @@ -18,7 +19,7 @@ class ThemeSettingsScreen extends StatelessWidget { return Scaffold( backgroundColor: cs.surface, - appBar: AppBarM3E(titleText: 'Тема', backgroundColor: cs.surface), + appBar: ConnectionTitleBar(titleText: 'Тема', backgroundColor: cs.surface), body: SafeArea( top: false, child: ListView( diff --git a/lib/frontend/screens/profile/web_qr_scan_screen.dart b/lib/frontend/screens/profile/web_qr_scan_screen.dart index a859b5a..ab893ff 100644 --- a/lib/frontend/screens/profile/web_qr_scan_screen.dart +++ b/lib/frontend/screens/profile/web_qr_scan_screen.dart @@ -7,6 +7,8 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; +import '../../widgets/connection_status.dart'; + class WebQrScanScreen extends StatefulWidget { const WebQrScanScreen({super.key}); @@ -58,6 +60,8 @@ class _WebQrScanScreenState extends State { Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, + floatingActionButtonLocation: FloatingActionButtonLocation.startFloat, + floatingActionButton: const ConnectionSpinner(), appBar: AppBar( backgroundColor: Colors.black, foregroundColor: Colors.white, diff --git a/lib/frontend/screens/webapp/web_app_screen.dart b/lib/frontend/screens/webapp/web_app_screen.dart index 8cf67f2..dbb1b9f 100644 --- a/lib/frontend/screens/webapp/web_app_screen.dart +++ b/lib/frontend/screens/webapp/web_app_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/webapp.dart'; +import '../../widgets/connection_status.dart'; import '../../widgets/webview_permission_prompt.dart'; class WebAppScreen extends StatefulWidget { @@ -67,6 +68,8 @@ class _WebAppScreenState extends State { }, child: Scaffold( backgroundColor: cs.surface, + floatingActionButtonLocation: FloatingActionButtonLocation.startFloat, + floatingActionButton: const ConnectionSpinner(), appBar: AppBar( backgroundColor: cs.surface, surfaceTintColor: Colors.transparent, diff --git a/lib/frontend/widgets/connection_status.dart b/lib/frontend/widgets/connection_status.dart new file mode 100644 index 0000000..c1ebfef --- /dev/null +++ b/lib/frontend/widgets/connection_status.dart @@ -0,0 +1,191 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:m3e_collection/m3e_collection.dart'; + +import '../../backend/api.dart'; +import '../../main.dart' show api; + +final ValueNotifier debugForceOffline = ValueNotifier(false); + +String? connectionStatusLabel(SessionState state) { + if (debugForceOffline.value) return 'Ожидание сети...'; + return switch (state) { + SessionState.online => null, + SessionState.connecting || SessionState.connected => 'Соединение...', + SessionState.disconnected => 'Ожидание сети...', + }; +} + +class ConnectionStatusBuilder extends StatefulWidget { + final Widget Function(BuildContext context, String? label) builder; + + const ConnectionStatusBuilder({super.key, required this.builder}); + + @override + State createState() => + _ConnectionStatusBuilderState(); +} + +class _ConnectionStatusBuilderState extends State { + late SessionState _state = api.state; + StreamSubscription? _sub; + + @override + void initState() { + super.initState(); + _sub = api.stateStream.listen((s) { + if (mounted && s != _state) setState(() => _state = s); + }); + debugForceOffline.addListener(_onOverrideChanged); + } + + void _onOverrideChanged() { + if (mounted) setState(() {}); + } + + @override + void dispose() { + _sub?.cancel(); + debugForceOffline.removeListener(_onOverrideChanged); + super.dispose(); + } + + @override + Widget build(BuildContext context) => + widget.builder(context, connectionStatusLabel(_state)); +} + +class ConnectionStatusLine extends StatelessWidget { + final TextAlign? textAlign; + final EdgeInsetsGeometry padding; + + const ConnectionStatusLine({ + super.key, + this.textAlign, + this.padding = EdgeInsets.zero, + }); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return ConnectionStatusBuilder( + builder: (context, label) => AnimatedSwitcher( + duration: const Duration(milliseconds: 220), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + transitionBuilder: (child, animation) => SizeTransition( + sizeFactor: animation, + axisAlignment: -1, + child: FadeTransition(opacity: animation, child: child), + ), + child: label == null + ? const SizedBox(width: double.infinity, key: ValueKey('online')) + : Padding( + key: const ValueKey('offline'), + padding: padding, + child: Text( + label, + textAlign: textAlign, + style: TextStyle( + color: cs.onSurfaceVariant.withValues(alpha: 0.55), + fontSize: 13, + fontWeight: FontWeight.w400, + ), + ), + ), + ), + ); + } +} + +class ConnectionTitleText extends StatelessWidget { + final String title; + final TextStyle? style; + final TextAlign? textAlign; + + const ConnectionTitleText( + this.title, { + super.key, + this.style, + this.textAlign, + }); + + @override + Widget build(BuildContext context) => ConnectionStatusBuilder( + builder: (context, label) => Text( + label ?? title, + style: style, + textAlign: textAlign, + overflow: TextOverflow.ellipsis, + ), + ); +} + +class ConnectionTitleBar extends StatelessWidget implements PreferredSizeWidget { + final String titleText; + final Color? backgroundColor; + + const ConnectionTitleBar({ + super.key, + required this.titleText, + this.backgroundColor, + }); + + @override + Size get preferredSize => const Size.fromHeight(64); + + @override + Widget build(BuildContext context) => ConnectionStatusBuilder( + builder: (context, label) => AppBarM3E( + titleText: label ?? titleText, + backgroundColor: backgroundColor, + ), + ); +} + +class ConnectionSpinner extends StatelessWidget { + const ConnectionSpinner({super.key}); + + @override + Widget build(BuildContext context) { + final cs = Theme.of(context).colorScheme; + return ConnectionStatusBuilder( + builder: (context, label) => AnimatedSwitcher( + duration: const Duration(milliseconds: 220), + switchInCurve: Curves.easeOutBack, + switchOutCurve: Curves.easeIn, + transitionBuilder: (child, animation) => + ScaleTransition(scale: animation, child: child), + child: label == null + ? const SizedBox.shrink(key: ValueKey('online')) + : Container( + key: const ValueKey('offline'), + width: 40, + height: 40, + decoration: BoxDecoration( + color: cs.surfaceContainerHighest, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.18), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Center( + child: SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2.4, + color: cs.primary, + ), + ), + ), + ), + ), + ); + } +}