diff --git a/lib/frontend/debug/cache_section.dart b/lib/frontend/debug/cache_section.dart index eeacb77..0b13f59 100644 --- a/lib/frontend/debug/cache_section.dart +++ b/lib/frontend/debug/cache_section.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../core/utils/format.dart'; +import '../widgets/small_spinner.dart'; class DebugCacheSection extends StatelessWidget { final int cacheSize; @@ -129,14 +130,7 @@ class DebugCacheSection extends StatelessWidget { ), ), if (clearingCache) - SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onSurfaceVariant, - ), - ), + SmallSpinner(size: 20, color: cs.onSurfaceVariant), ], ), ), diff --git a/lib/frontend/debug/id_search_section.dart b/lib/frontend/debug/id_search_section.dart index b8ee1d3..ba9360a 100644 --- a/lib/frontend/debug/id_search_section.dart +++ b/lib/frontend/debug/id_search_section.dart @@ -5,6 +5,7 @@ import 'package:material_symbols_icons/symbols.dart'; import '../widgets/custom_notification.dart'; import '../widgets/glossy_pill.dart'; +import '../widgets/small_spinner.dart'; class DebugIdSearchSection extends StatelessWidget { final TextEditingController idController; @@ -72,11 +73,7 @@ class DebugIdSearchSection extends StatelessWidget { FilledButton( onPressed: isSearching ? null : onSearch, child: isSearching - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) + ? const SmallSpinner(size: 20) : const Icon(Symbols.search, size: 20), ), ], diff --git a/lib/frontend/debug/sync_probe_section.dart b/lib/frontend/debug/sync_probe_section.dart index 48c696b..197f5d6 100644 --- a/lib/frontend/debug/sync_probe_section.dart +++ b/lib/frontend/debug/sync_probe_section.dart @@ -6,6 +6,7 @@ import '../../core/protocol/opcode_map.dart'; import '../../core/protocol/packet.dart'; import '../../main.dart'; import '../widgets/glossy_pill.dart'; +import '../widgets/small_spinner.dart'; class DebugSyncProbeSection extends StatefulWidget { const DebugSyncProbeSection({super.key}); @@ -147,11 +148,7 @@ class _DebugSyncProbeSectionState extends State { ), ), child: _loading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) + ? const SmallSpinner(size: 20) : const Text('Отправить'), ), if (_result != null) ...[ diff --git a/lib/frontend/screens/auth/code_confirmation_screen.dart b/lib/frontend/screens/auth/code_confirmation_screen.dart index 8e4484b..0ef603f 100644 --- a/lib/frontend/screens/auth/code_confirmation_screen.dart +++ b/lib/frontend/screens/auth/code_confirmation_screen.dart @@ -11,6 +11,7 @@ import '../../../core/utils/sms_code_listener.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/login_success_screen.dart'; +import '../../widgets/small_spinner.dart'; class CodeConfirmationScreen extends StatefulWidget { final String phoneNumber; @@ -525,14 +526,7 @@ class _CodeConfirmationScreenState extends State borderRadius: BorderRadius.circular(50), ), child: recovering - ? SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onPrimaryContainer, - ), - ) + ? SmallSpinner(size: 24, color: cs.onPrimaryContainer) : Icon( Icons.arrow_forward, color: _codeController.text.length == 6 diff --git a/lib/frontend/screens/auth/login_screen.dart b/lib/frontend/screens/auth/login_screen.dart index 398b8de..ec3bb9c 100644 --- a/lib/frontend/screens/auth/login_screen.dart +++ b/lib/frontend/screens/auth/login_screen.dart @@ -18,6 +18,7 @@ import '../digital_id/digital_id_web_screen.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/adaptive_shell.dart'; import '../../widgets/sheet_helpers.dart'; +import '../../widgets/small_spinner.dart'; import '../../../backend/api.dart'; import '../../../core/protocol/packet.dart'; import '../../../main.dart'; @@ -998,13 +999,9 @@ class _LoginScreenState extends State { borderRadius: BorderRadius.circular(50), ), child: _isPhoneValid && !_isOnline - ? SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onPrimaryContainer, - ), + ? SmallSpinner( + size: 24, + color: cs.onPrimaryContainer, ) : Icon( Icons.arrow_forward, diff --git a/lib/frontend/screens/auth/password_2fa_screen.dart b/lib/frontend/screens/auth/password_2fa_screen.dart index dae30dd..2a6fd9e 100644 --- a/lib/frontend/screens/auth/password_2fa_screen.dart +++ b/lib/frontend/screens/auth/password_2fa_screen.dart @@ -3,6 +3,7 @@ import '../../../core/protocol/packet.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/login_success_screen.dart'; +import '../../widgets/small_spinner.dart'; import 'session_stale_recovery.dart'; class Password2FAScreen extends StatefulWidget { @@ -199,14 +200,7 @@ class _Password2FAScreenState extends State borderRadius: BorderRadius.circular(50), ), child: _isLoading - ? SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onPrimaryContainer, - ), - ) + ? SmallSpinner(size: 24, color: cs.onPrimaryContainer) : Icon( Icons.arrow_forward, color: _passwordController.text.isNotEmpty diff --git a/lib/frontend/screens/auth/registration_screen.dart b/lib/frontend/screens/auth/registration_screen.dart index 2a6b1da..86b72d1 100644 --- a/lib/frontend/screens/auth/registration_screen.dart +++ b/lib/frontend/screens/auth/registration_screen.dart @@ -6,6 +6,7 @@ import '../../../backend/modules/account.dart'; import '../../../main.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/login_success_screen.dart'; +import '../../widgets/small_spinner.dart'; class RegistrationScreen extends StatefulWidget { final String phoneNumber; @@ -110,14 +111,7 @@ class _RegistrationScreenState extends State { elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(50)), child: _isSubmitting - ? SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator( - strokeWidth: 2.5, - color: cs.onSurfaceVariant, - ), - ) + ? SmallSpinner(size: 22, color: cs.onSurfaceVariant) : Icon( Icons.arrow_forward, color: _canSubmit ? cs.onPrimaryContainer : cs.onSurfaceVariant, diff --git a/lib/frontend/screens/auth/token_login_screen.dart b/lib/frontend/screens/auth/token_login_screen.dart index 382df99..58bb761 100644 --- a/lib/frontend/screens/auth/token_login_screen.dart +++ b/lib/frontend/screens/auth/token_login_screen.dart @@ -8,6 +8,7 @@ import '../../../models/spoof_profile.dart'; import '../../widgets/adaptive_shell.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/section_header.dart'; +import '../../widgets/small_spinner.dart'; class TokenLoginScreen extends StatefulWidget { final int? returnToAccountId; @@ -143,11 +144,7 @@ class _TokenLoginScreenState extends State { shape: const StadiumBorder(), ), child: _isLoading - ? const SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator(strokeWidth: 2), - ) + ? const SmallSpinner(size: 22) : Text(l10n.tokenLoginButton), ), ), diff --git a/lib/frontend/screens/calls/call_screen.dart b/lib/frontend/screens/calls/call_screen.dart index 42ac11f..c11295e 100644 --- a/lib/frontend/screens/calls/call_screen.dart +++ b/lib/frontend/screens/calls/call_screen.dart @@ -24,6 +24,7 @@ import '../../../l10n/app_localizations.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/sheet_helpers.dart'; +import '../../widgets/small_spinner.dart'; import 'komet_hub.dart'; const Color _kEndRed = Color(0xFFE5484D); @@ -1261,14 +1262,7 @@ class _CallButton extends StatelessWidget { depth: 9, child: Center( child: busy - ? SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator( - strokeWidth: 2.5, - valueColor: AlwaysStoppedAnimation(foreground), - ), - ) + ? SmallSpinner(size: 22, color: foreground) : Icon(icon, color: foreground, size: 26, fill: 1), ), ), diff --git a/lib/frontend/screens/calls/calls_tab.dart b/lib/frontend/screens/calls/calls_tab.dart index 8dd0c37..5068710 100644 --- a/lib/frontend/screens/calls/calls_tab.dart +++ b/lib/frontend/screens/calls/calls_tab.dart @@ -12,6 +12,7 @@ import '../../widgets/komet_avatar.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/chat_menu_overlay.dart'; +import '../../widgets/small_spinner.dart'; import 'call_screen.dart'; class CallsTab extends StatefulWidget { @@ -421,7 +422,7 @@ class _CallsTabState extends State { ), Expanded( child: _isLoading - ? const Center(child: CircularProgressIndicator()) + ? const Center(child: SmallSpinner(size: 36)) : filteredCalls.isEmpty ? Center( child: Text( diff --git a/lib/frontend/screens/chats/chat/view/search_view.dart b/lib/frontend/screens/chats/chat/view/search_view.dart index 2c7c683..06c5c0d 100644 --- a/lib/frontend/screens/chats/chat/view/search_view.dart +++ b/lib/frontend/screens/chats/chat/view/search_view.dart @@ -9,6 +9,7 @@ import 'package:komet/core/utils/format.dart'; import 'package:komet/frontend/widgets/animated_lottie_icon.dart'; import 'package:komet/frontend/widgets/glossy_pill.dart'; import 'package:komet/frontend/widgets/komet_avatar.dart'; +import 'package:komet/frontend/widgets/small_spinner.dart'; import 'package:komet/frontend/screens/chats/chat/chat_search_controller.dart'; import 'package:komet/frontend/screens/chats/chat/message_search_result.dart'; @@ -168,14 +169,7 @@ class SearchOverlay extends StatelessWidget { } if (loading) { return Center( - child: SizedBox( - width: 26, - height: 26, - child: CircularProgressIndicator( - strokeWidth: 2.4, - color: cs.onSurfaceVariant, - ), - ), + child: SmallSpinner(size: 26, color: cs.onSurfaceVariant), ); } return ValueListenableBuilder( diff --git a/lib/frontend/screens/chats/chat_list_screen.dart b/lib/frontend/screens/chats/chat_list_screen.dart index 282c669..083b7ca 100644 --- a/lib/frontend/screens/chats/chat_list_screen.dart +++ b/lib/frontend/screens/chats/chat_list_screen.dart @@ -16,6 +16,7 @@ import '../../widgets/glossy_pill.dart'; import '../../widgets/sheet_helpers.dart'; import '../../widgets/swipe_route.dart'; import '../../widgets/sliding_pill_nav.dart'; +import '../../widgets/springy_tap.dart'; import '../../widgets/formatted_message_text.dart'; import '../../../core/utils/format.dart'; import '../../../core/utils/text_format.dart'; @@ -2550,219 +2551,226 @@ class _ChatListScreenState extends State ) : null, ); - return InkWell( + return SpringyTap( key: ValueKey('chat_$id'), - onTap: () { - if (widget.forwardMode) { - Navigator.of(context).pop( - ForwardTarget( - chatId: int.parse(id), - name: name, - imageUrl: imageUrl, - chatType: chatType, - ), - ); - return; - } - if (_isSelectionMode) { - _toggleSelection(id); - return; - } - if (imageUrl.isNotEmpty) { - unawaited( - precacheImage( - CachedNetworkImageProvider( - imageUrl, - maxWidth: kAvatarThumbSize, - maxHeight: kAvatarThumbSize, + child: InkWell( + onTap: () { + if (widget.forwardMode) { + Navigator.of(context).pop( + ForwardTarget( + chatId: int.parse(id), + name: name, + imageUrl: imageUrl, + chatType: chatType, ), + ); + return; + } + if (_isSelectionMode) { + _toggleSelection(id); + return; + } + if (imageUrl.isNotEmpty) { + unawaited( + precacheImage( + CachedNetworkImageProvider( + imageUrl, + maxWidth: kAvatarThumbSize, + maxHeight: kAvatarThumbSize, + ), + context, + ), + ); + } + if (widget.onChatSelected != null) { + widget.onChatSelected!( + DesktopChatSelection( + chatId: int.parse(id), + name: name, + imageUrl: imageUrl, + chatType: chatType, + ), + ); + } else { + pushSwipeable( context, - ), - ); - } - if (widget.onChatSelected != null) { - widget.onChatSelected!( - DesktopChatSelection( - chatId: int.parse(id), - name: name, - imageUrl: imageUrl, - chatType: chatType, - ), - ); - } else { - pushSwipeable( - context, - (context) => ChatScreen( - chatId: int.parse(id), - name: name, - imageUrl: imageUrl, - chatType: chatType, - ), - ); - } - }, - onLongPress: widget.forwardMode ? null : () => _toggleSelection(id), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - color: isSelected - ? cs.primary.withValues(alpha: 0.08) - : Colors.transparent, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Stack( - children: [ - avatarCircle, - if (isSelected) - Positioned( - right: -2, - bottom: -2, - child: Container( - width: 20, - height: 20, - decoration: BoxDecoration( - color: cs.primary, - shape: BoxShape.circle, - border: Border.all(color: cs.surface, width: 2), - ), - child: Icon( - Symbols.check, - color: cs.onPrimary, - size: 14, - ), - ), - ) - else if (presenceUserId != 0) - Positioned( - right: 0, - bottom: 0, - child: OnlineDot( - userId: presenceUserId, - borderColor: cs.surface, - ), - ), - ], + (context) => ChatScreen( + chatId: int.parse(id), + name: name, + imageUrl: imageUrl, + chatType: chatType, ), - const SizedBox(width: 12), - Expanded( - child: SizedBox( - height: 48, - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Padding( - padding: const EdgeInsets.only(top: 5), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - name, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w600, - height: 1.1, + ); + } + }, + onLongPress: widget.forwardMode ? null : () => _toggleSelection(id), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + color: isSelected + ? cs.primary.withValues(alpha: 0.08) + : Colors.transparent, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Stack( + children: [ + avatarCircle, + if (isSelected) + Positioned( + right: -2, + bottom: -2, + child: Container( + width: 20, + height: 20, + decoration: BoxDecoration( + color: cs.primary, + shape: BoxShape.circle, + border: Border.all(color: cs.surface, width: 2), + ), + child: Icon( + Symbols.check, + color: cs.onPrimary, + size: 14, + ), + ), + ) + else if (presenceUserId != 0) + Positioned( + right: 0, + bottom: 0, + child: OnlineDot( + userId: presenceUserId, + borderColor: cs.surface, + ), + ), + ], + ), + const SizedBox(width: 12), + Expanded( + child: SizedBox( + height: 48, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Padding( + padding: const EdgeInsets.only(top: 5), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + name, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + height: 1.1, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (isVerified) ...[ - const SizedBox(width: 4), - Icon( - Symbols.verified, - color: cs.primary, - size: 16, - weight: 600, - fill: 1, ), + if (isVerified) ...[ + const SizedBox(width: 4), + Icon( + Symbols.verified, + color: cs.primary, + size: 16, + weight: 600, + fill: 1, + ), + ], ], - ], + ), ), - ), - if (isMuted) ...[ - const SizedBox(width: 4), - Icon( - Symbols.notifications_off, - color: cs.outlineVariant, - size: 14, - weight: 400, + if (isMuted) ...[ + const SizedBox(width: 4), + Icon( + Symbols.notifications_off, + color: cs.outlineVariant, + size: 14, + weight: 400, + ), + ], + if (isPinned) ...[ + const SizedBox(width: 4), + Icon( + Symbols.keep, + color: cs.outlineVariant, + size: 14, + weight: 400, + ), + ], + const SizedBox(width: 8), + Text( + time, + style: TextStyle( + color: cs.outline, + fontSize: 12, + ), ), ], - if (isPinned) ...[ - const SizedBox(width: 4), - Icon( - Symbols.keep, - color: cs.outlineVariant, - size: 14, - weight: 400, - ), - ], - const SizedBox(width: 8), - Text( - time, - style: TextStyle(color: cs.outline, fontSize: 12), - ), - ], + ), ), - ), - Padding( - padding: const EdgeInsets.only(bottom: 2), - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Expanded( - child: ActivitySubtitle( - chatId: int.tryParse(id) ?? 0, - child: messageLine, + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: ActivitySubtitle( + chatId: int.tryParse(id) ?? 0, + child: messageLine, + ), ), - ), - ?statusIcon, - const SizedBox(width: 8), - if (unreadCount > 0) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: isMuted - ? cs.surfaceContainerHighest - : cs.primary, - borderRadius: BorderRadius.circular(10), - ), - child: Text( - unreadCount.toString(), - style: TextStyle( - color: isMuted ? cs.outline : cs.onPrimary, - fontSize: 11, - fontWeight: FontWeight.w600, - height: 1.1, + ?statusIcon, + const SizedBox(width: 8), + if (unreadCount > 0) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, ), + decoration: BoxDecoration( + color: isMuted + ? cs.surfaceContainerHighest + : cs.primary, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + unreadCount.toString(), + style: TextStyle( + color: isMuted + ? cs.outline + : cs.onPrimary, + fontSize: 11, + fontWeight: FontWeight.w600, + height: 1.1, + ), + ), + ) + else if (isRead) + Icon( + Symbols.done_all, + color: cs.primary, + size: 16, + weight: 400, ), - ) - else if (isRead) - Icon( - Symbols.done_all, - color: cs.primary, - size: 16, - weight: 400, - ), - ], + ], + ), ), - ), - ], + ], + ), ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/frontend/screens/chats/chat_screen.dart b/lib/frontend/screens/chats/chat_screen.dart index ffc8617..a2440d1 100644 --- a/lib/frontend/screens/chats/chat_screen.dart +++ b/lib/frontend/screens/chats/chat_screen.dart @@ -87,6 +87,7 @@ import '../../widgets/lottie_image.dart'; import '../../widgets/attachment_panel.dart'; import '../../widgets/attachment/attachment_sheet.dart'; import '../../widgets/sticker_pack_sheet.dart'; +import '../../widgets/small_spinner.dart'; import '../../widgets/swipe_to_pop.dart'; import '../../widgets/swipe_route.dart'; import '../../widgets/schedule_time_picker.dart'; @@ -4610,14 +4611,7 @@ class _ChatScreenState extends State return Padding( padding: const EdgeInsets.symmetric(vertical: 12), child: Center( - child: SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator( - strokeWidth: 2.2, - color: cs.onSurfaceVariant, - ), - ), + child: SmallSpinner(size: 22, color: cs.onSurfaceVariant), ), ); } diff --git a/lib/frontend/screens/chats/create_group_flow.dart b/lib/frontend/screens/chats/create_group_flow.dart index b7566fd..d36b925 100644 --- a/lib/frontend/screens/chats/create_group_flow.dart +++ b/lib/frontend/screens/chats/create_group_flow.dart @@ -13,6 +13,7 @@ import '../../../main.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/komet_avatar.dart'; import '../../widgets/sheet_helpers.dart'; +import '../../widgets/small_spinner.dart'; import '../../widgets/swipe_route.dart'; import 'chat_screen.dart'; @@ -295,7 +296,7 @@ class _CreateGroupFlowState extends State<_CreateGroupFlow> { child: _loading ? const Padding( padding: EdgeInsets.all(24), - child: Center(child: CircularProgressIndicator()), + child: Center(child: SmallSpinner(size: 36)), ) : ListView.builder( padding: EdgeInsets.zero, diff --git a/lib/frontend/screens/chats/scheduled_messages_screen.dart b/lib/frontend/screens/chats/scheduled_messages_screen.dart index 2e98c14..d10c6e1 100644 --- a/lib/frontend/screens/chats/scheduled_messages_screen.dart +++ b/lib/frontend/screens/chats/scheduled_messages_screen.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:m3e_collection/m3e_collection.dart' + show ExpressiveRefreshIndicator; import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/messages.dart'; @@ -15,6 +17,7 @@ import '../../widgets/confirm_dialog.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/schedule_time_picker.dart'; import '../../widgets/sheet_helpers.dart'; +import '../../widgets/small_spinner.dart'; class ScheduledMessagesScreen extends StatefulWidget { final int chatId; @@ -258,10 +261,10 @@ class _ScheduledMessagesScreenState extends State { ), ), body: _loading - ? const Center(child: CircularProgressIndicator()) + ? const Center(child: SmallSpinner(size: 36)) : _messages.isEmpty ? _empty(cs) - : RefreshIndicator( + : ExpressiveRefreshIndicator( onRefresh: _load, child: ListView.separated( padding: const EdgeInsets.all(16), diff --git a/lib/frontend/screens/chats/search_screen.dart b/lib/frontend/screens/chats/search_screen.dart index ac9c317..ed80827 100644 --- a/lib/frontend/screens/chats/search_screen.dart +++ b/lib/frontend/screens/chats/search_screen.dart @@ -10,6 +10,7 @@ import '../../../core/storage/app_database.dart'; import '../../../core/utils/debouncer.dart'; import '../../../core/utils/names.dart'; import '../../widgets/komet_avatar.dart'; +import '../../widgets/small_spinner.dart'; import '../../widgets/swipe_route.dart'; import '../contacts/open_contact_profile.dart'; import 'chat_screen.dart'; @@ -232,7 +233,7 @@ class _SearchScreenState extends State { } if (!hasResults) { if (_loading) { - return const Center(child: CircularProgressIndicator()); + return const Center(child: SmallSpinner(size: 36)); } return _buildHint(cs, Symbols.search_off, 'Ничего не найдено'); } diff --git a/lib/frontend/screens/contacts/contacts_tab.dart b/lib/frontend/screens/contacts/contacts_tab.dart index edb5a1b..c3cbcff 100644 --- a/lib/frontend/screens/contacts/contacts_tab.dart +++ b/lib/frontend/screens/contacts/contacts_tab.dart @@ -11,6 +11,8 @@ import '../../../models/contact_info.dart'; import '../../widgets/komet_avatar.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/sheet_helpers.dart'; +import '../../widgets/small_spinner.dart'; +import '../../widgets/springy_tap.dart'; import '../chats/chat_info_screen.dart'; import 'nfc_exchange_sheet.dart'; import 'open_contact_profile.dart'; @@ -115,83 +117,85 @@ class _ContactsTabState extends State { .trim(); final nameToDisplay = fullName.isEmpty ? '+${contact.phone}' : fullName; - return Material( - color: Colors.transparent, - child: InkWell( - onTap: () => openContactDialogProfile( - context, - contactId: contact.id, - name: nameToDisplay, - avatarUrl: contact.baseUrl, - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), - child: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - shape: BoxShape.circle, - border: Border.all( - color: cs.primary.withValues(alpha: 0.1), - width: 1, + return SpringyTap( + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () => openContactDialogProfile( + context, + contactId: contact.id, + name: nameToDisplay, + avatarUrl: contact.baseUrl, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + child: Row( + children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: cs.primary.withValues(alpha: 0.1), + width: 1, + ), + ), + child: KometAvatar( + name: nameToDisplay, + imageUrl: contact.baseUrl, + size: 48, ), ), - child: KometAvatar( - name: nameToDisplay, - imageUrl: contact.baseUrl, - size: 48, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - nameToDisplay, - style: TextStyle( - color: cs.onSurface, - fontSize: 16, - fontWeight: FontWeight.w600, + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + nameToDisplay, + style: TextStyle( + color: cs.onSurface, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (contact.isVerified) ...[ - const SizedBox(width: 4), - Icon( - Symbols.verified, - color: cs.primary, - size: 16, - weight: 600, - fill: 1, ), + if (contact.isVerified) ...[ + const SizedBox(width: 4), + Icon( + Symbols.verified, + color: cs.primary, + size: 16, + weight: 600, + fill: 1, + ), + ], ], - ], - ), - const SizedBox(height: 4), - Text( - contact.updateTime > 0 - ? 'Был(а) недавно' - : '+${contact.phone}', - style: TextStyle( - color: cs.onSurfaceVariant, - fontSize: 14, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], + const SizedBox(height: 4), + Text( + contact.updateTime > 0 + ? 'Был(а) недавно' + : '+${contact.phone}', + style: TextStyle( + color: cs.onSurfaceVariant, + fontSize: 14, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), ), - ), - ], + ], + ), ), ), ), @@ -243,7 +247,7 @@ class _ContactsTabState extends State { ), Expanded( child: _isLoading - ? const Center(child: CircularProgressIndicator()) + ? const Center(child: SmallSpinner(size: 36)) : _contacts.isEmpty ? Center( child: Text( @@ -457,11 +461,7 @@ class _SearchContactSheetState extends State<_SearchContactSheet> { padding: const EdgeInsets.symmetric(vertical: 14), ), child: _loading - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) + ? const SmallSpinner(size: 20) : const Text('Найти'), ), ], diff --git a/lib/frontend/screens/contacts/nfc_exchange_sheet.dart b/lib/frontend/screens/contacts/nfc_exchange_sheet.dart index ab35f3e..2ff68dc 100644 --- a/lib/frontend/screens/contacts/nfc_exchange_sheet.dart +++ b/lib/frontend/screens/contacts/nfc_exchange_sheet.dart @@ -15,6 +15,7 @@ import '../../../main.dart'; import '../../../models/contact_info.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/komet_avatar.dart'; +import '../../widgets/small_spinner.dart'; enum _Stage { checking, @@ -243,7 +244,7 @@ class _NfcExchangeSheetState extends State case _Stage.checking: return const Padding( padding: EdgeInsets.symmetric(vertical: 40), - child: CircularProgressIndicator(), + child: SmallSpinner(size: 36), ); case _Stage.unsupported: return _message(cs, Symbols.nfc, l10n.nfcUnsupported); @@ -420,11 +421,7 @@ class _NfcExchangeSheetState extends State padding: const EdgeInsets.symmetric(vertical: 14), ), child: _stage == _Stage.adding - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) + ? const SmallSpinner(size: 20) : Text( _stage == _Stage.added ? l10n.nfcAdded diff --git a/lib/frontend/screens/digital_id/digital_id_screen.dart b/lib/frontend/screens/digital_id/digital_id_screen.dart index 9998de7..e5390f9 100644 --- a/lib/frontend/screens/digital_id/digital_id_screen.dart +++ b/lib/frontend/screens/digital_id/digital_id_screen.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:m3e_collection/m3e_collection.dart' + show ExpressiveRefreshIndicator; import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/digital_id.dart'; @@ -10,6 +12,7 @@ import '../../../models/digital_id.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/error_view.dart'; +import '../../widgets/small_spinner.dart'; import '../webapp/web_app_screen.dart'; String _documentLabel(AppLocalizations l10n, String type) { @@ -198,7 +201,7 @@ class _DigitalIdScreenState extends State { Widget _buildBody(ColorScheme cs) { if (_loading) { - return const Center(child: CircularProgressIndicator()); + return const Center(child: SmallSpinner(size: 36)); } if (_error != null) { return ErrorView(message: _error!, onRetry: _load); @@ -206,7 +209,7 @@ class _DigitalIdScreenState extends State { if (_docs == null) { return _buildOnboarding(cs); } - return RefreshIndicator( + return ExpressiveRefreshIndicator( onRefresh: _load, child: ListView( physics: const AlwaysScrollableScrollPhysics(), @@ -278,11 +281,7 @@ class _DigitalIdScreenState extends State { child: FilledButton.icon( onPressed: _busy ? null : _linkGosuslugi, icon: _busy - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) + ? const SmallSpinner(size: 18) : const Icon(Symbols.link, size: 18), label: Text( l10n.digitalIdLinkGosuslugiButton, diff --git a/lib/frontend/screens/profile/cloud_storage_screen.dart b/lib/frontend/screens/profile/cloud_storage_screen.dart index 4016087..7a40dee 100644 --- a/lib/frontend/screens/profile/cloud_storage_screen.dart +++ b/lib/frontend/screens/profile/cloud_storage_screen.dart @@ -18,6 +18,7 @@ import '../../widgets/connection_status.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/sheet_helpers.dart'; +import '../../widgets/small_spinner.dart'; enum _EnvState { loading, notConfigured, ready } @@ -333,7 +334,7 @@ class _CloudStorageScreenState extends State ), ), body: switch (_envState) { - _EnvState.loading => const Center(child: CircularProgressIndicator()), + _EnvState.loading => const Center(child: SmallSpinner(size: 36)), _EnvState.notConfigured => _buildNotConfigured(cs), _EnvState.ready => _buildReady(cs), }, @@ -375,14 +376,7 @@ class _CloudStorageScreenState extends State ), ), child: _isCreatingEnv - ? SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onPrimary, - ), - ) + ? SmallSpinner(size: 18, color: cs.onPrimary) : Text( l10n.cloudStorageStart, style: const TextStyle( @@ -1053,14 +1047,7 @@ class _FileDetailsSheetState extends State<_FileDetailsSheet> { ), const SizedBox(width: 8), _loading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.primary, - ), - ) + ? SmallSpinner(size: 20, color: cs.primary) : IconButton( icon: Icon( isExpired ? Symbols.add_link : Symbols.content_copy, @@ -1221,14 +1208,7 @@ class _SendByIdSheetState extends State<_SendByIdSheet> { ), ), child: _sending - ? SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onPrimary, - ), - ) + ? SmallSpinner(size: 18, color: cs.onPrimary) : Text( l10n.cloudStorageSend, style: const TextStyle(fontWeight: FontWeight.w600), diff --git a/lib/frontend/screens/profile/devices_screen.dart b/lib/frontend/screens/profile/devices_screen.dart index 53a15f3..96ce1fa 100644 --- a/lib/frontend/screens/profile/devices_screen.dart +++ b/lib/frontend/screens/profile/devices_screen.dart @@ -14,6 +14,7 @@ import '../../widgets/custom_notification.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/prompt_dialog.dart'; +import '../../widgets/small_spinner.dart'; import '../../widgets/web_qr_login.dart'; import 'web_qr_scan_screen.dart'; @@ -533,14 +534,10 @@ class _DevicesScreenState extends State child: Container( padding: const EdgeInsets.all(4), child: isLoading - ? SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.onSurfaceVariant.withValues( - alpha: 0.5, - ), + ? SmallSpinner( + size: 14, + color: cs.onSurfaceVariant.withValues( + alpha: 0.5, ), ) : Icon( diff --git a/lib/frontend/screens/profile/edit_profile_screen.dart b/lib/frontend/screens/profile/edit_profile_screen.dart index 1bc23c6..dd2eb7a 100644 --- a/lib/frontend/screens/profile/edit_profile_screen.dart +++ b/lib/frontend/screens/profile/edit_profile_screen.dart @@ -8,6 +8,7 @@ import '../../../main.dart' show accountModule, fileUploader, KometApp; import '../../widgets/connection_status.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/komet_avatar.dart'; +import '../../widgets/small_spinner.dart'; class EditProfileScreen extends StatefulWidget { const EditProfileScreen({super.key}); @@ -180,11 +181,7 @@ class _EditProfileScreenState extends State { TextButton( onPressed: _isLoading || _isSaving ? null : _saveName, child: _isSaving - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) + ? const SmallSpinner(size: 16) : Text( l10n?.editProfileSave ?? 'Save', style: TextStyle( @@ -196,7 +193,7 @@ class _EditProfileScreenState extends State { ], ), body: _isLoading - ? const Center(child: CircularProgressIndicator()) + ? const Center(child: SmallSpinner(size: 36)) : ListView( padding: const EdgeInsets.all(16), children: [ diff --git a/lib/frontend/screens/profile/info_screen.dart b/lib/frontend/screens/profile/info_screen.dart index b528f40..b2a6cad 100644 --- a/lib/frontend/screens/profile/info_screen.dart +++ b/lib/frontend/screens/profile/info_screen.dart @@ -9,6 +9,7 @@ import '../../widgets/connection_status.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/section_header.dart'; +import '../../widgets/small_spinner.dart'; class InfoScreen extends StatefulWidget { const InfoScreen({super.key}); @@ -68,7 +69,7 @@ class _InfoScreenState extends State { ), ), body: _isLoading - ? const Center(child: CircularProgressIndicator()) + ? const Center(child: SmallSpinner(size: 36)) : _info == null ? Center( child: Text( diff --git a/lib/frontend/screens/profile/notifications_screen.dart b/lib/frontend/screens/profile/notifications_screen.dart index bb8fd25..ec0144a 100644 --- a/lib/frontend/screens/profile/notifications_screen.dart +++ b/lib/frontend/screens/profile/notifications_screen.dart @@ -8,6 +8,7 @@ import '../../widgets/connection_status.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/section_header.dart'; import '../../widgets/settings_card.dart'; +import '../../widgets/small_spinner.dart'; class NotificationsScreen extends StatefulWidget { const NotificationsScreen({super.key}); @@ -101,7 +102,7 @@ class _NotificationsScreenState extends State { body: SafeArea( top: false, child: _loading - ? const Center(child: CircularProgressIndicator()) + ? const Center(child: SmallSpinner(size: 36)) : ListView( physics: const BouncingScrollPhysics(), padding: const EdgeInsets.fromLTRB(16, 12, 16, 120), diff --git a/lib/frontend/screens/profile/password_entry_screen.dart b/lib/frontend/screens/profile/password_entry_screen.dart index cfca998..1256a46 100644 --- a/lib/frontend/screens/profile/password_entry_screen.dart +++ b/lib/frontend/screens/profile/password_entry_screen.dart @@ -7,6 +7,7 @@ import '../../../l10n/app_localizations.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/primary_loading_button.dart'; +import '../../widgets/small_spinner.dart'; class PasswordEntryScreen extends StatefulWidget { const PasswordEntryScreen({super.key}); @@ -142,7 +143,7 @@ class _PasswordEntryScreenState extends State { if (_isLoading) { return Scaffold( backgroundColor: cs.surface, - body: Center(child: CircularProgressIndicator(color: cs.primary)), + body: Center(child: SmallSpinner(size: 36, color: cs.primary)), ); } diff --git a/lib/frontend/screens/profile/security_screen.dart b/lib/frontend/screens/profile/security_screen.dart index 8feed08..26aab4e 100644 --- a/lib/frontend/screens/profile/security_screen.dart +++ b/lib/frontend/screens/profile/security_screen.dart @@ -12,6 +12,7 @@ import '../../widgets/custom_notification.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/glossy_pill.dart'; import '../../widgets/sheet_helpers.dart'; +import '../../widgets/small_spinner.dart'; import 'password_entry_screen.dart'; class SecurityScreen extends StatefulWidget { @@ -219,14 +220,7 @@ class _SecurityScreenState extends State if (_isSaving) Padding( padding: const EdgeInsets.only(right: 16), - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: cs.primary, - ), - ), + child: SmallSpinner(size: 20, color: cs.primary), ), ], ), diff --git a/lib/frontend/screens/profile/settings_tab.dart b/lib/frontend/screens/profile/settings_tab.dart index 72833f4..7f8eb94 100644 --- a/lib/frontend/screens/profile/settings_tab.dart +++ b/lib/frontend/screens/profile/settings_tab.dart @@ -17,6 +17,7 @@ import '../../widgets/info_action_sheet.dart'; import '../../widgets/komet_avatar.dart'; import '../../widgets/settings_card.dart'; import '../../widgets/sheet_helpers.dart'; +import '../../widgets/small_spinner.dart'; import '../../widgets/custom_notification.dart'; import '../auth/login_screen.dart'; import '../auth/proxy_settings_sheet.dart'; @@ -222,7 +223,7 @@ class _SettingsTabState extends State { final cs = Theme.of(context).colorScheme; if (_profile == null) { - return const Center(child: CircularProgressIndicator()); + return const Center(child: SmallSpinner(size: 36)); } final String fullName = diff --git a/lib/frontend/screens/profile/spoof_screen.dart b/lib/frontend/screens/profile/spoof_screen.dart index a84a334..faf295e 100644 --- a/lib/frontend/screens/profile/spoof_screen.dart +++ b/lib/frontend/screens/profile/spoof_screen.dart @@ -18,6 +18,7 @@ import '../../widgets/connection_status.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/info_action_sheet.dart'; import '../../widgets/section_header.dart'; +import '../../widgets/small_spinner.dart'; import '../auth/login_screen.dart'; enum SpoofingMethod { partial, full } @@ -444,7 +445,7 @@ class _SpoofScreenState extends State { centerTitle: true, ), body: _isLoading - ? const Center(child: CircularProgressIndicator()) + ? const Center(child: SmallSpinner(size: 36)) : SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 8, 16, 120), child: Column( diff --git a/lib/frontend/screens/stories/story_viewer_screen.dart b/lib/frontend/screens/stories/story_viewer_screen.dart index 7c9e7a7..18f2aad 100644 --- a/lib/frontend/screens/stories/story_viewer_screen.dart +++ b/lib/frontend/screens/stories/story_viewer_screen.dart @@ -14,6 +14,7 @@ import '../../../models/story.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/komet_avatar.dart'; import '../../widgets/liquid_glass.dart'; +import '../../widgets/small_spinner.dart'; import 'story_owner_info.dart'; const _quickReactions = ['❤️', '🔥', '😍', '👏', '😂', '😮']; @@ -490,14 +491,7 @@ class _StoryViewerScreenState extends State const _TopScrim(), if (loading) const Center( - child: SizedBox( - width: 28, - height: 28, - child: CircularProgressIndicator( - strokeWidth: 2.4, - color: Colors.white, - ), - ), + child: SmallSpinner(size: 28, color: Colors.white), ), SafeArea( child: Column( @@ -1069,14 +1063,7 @@ class _OwnerCover extends StatelessWidget { imageUrl: info?.avatarUrl, ), const SizedBox(height: 14), - const SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white30, - ), - ), + const SmallSpinner(size: 22, color: Colors.white30), ], ), ), diff --git a/lib/frontend/screens/webapp/web_app_screen.dart b/lib/frontend/screens/webapp/web_app_screen.dart index 1acfd16..e850b6d 100644 --- a/lib/frontend/screens/webapp/web_app_screen.dart +++ b/lib/frontend/screens/webapp/web_app_screen.dart @@ -9,6 +9,7 @@ import '../../../core/storage/spoofing_service.dart'; import '../../../main.dart' show api; import '../../widgets/connection_status.dart'; import '../../widgets/error_view.dart'; +import '../../widgets/small_spinner.dart'; import '../../widgets/webview_permission_prompt.dart'; class WebAppScreen extends StatefulWidget { @@ -137,7 +138,7 @@ class _WebAppScreenState extends State { } final launch = _launch; if (launch == null) { - return const Center(child: CircularProgressIndicator()); + return const Center(child: SmallSpinner(size: 36)); } return InAppWebView( initialUrlRequest: URLRequest(url: WebUri(launch.url)), diff --git a/lib/frontend/widgets/attachment/attachment_sheet.dart b/lib/frontend/widgets/attachment/attachment_sheet.dart index 21d7fc3..4856c19 100644 --- a/lib/frontend/widgets/attachment/attachment_sheet.dart +++ b/lib/frontend/widgets/attachment/attachment_sheet.dart @@ -16,6 +16,8 @@ import 'package:komet/frontend/widgets/sheet_helpers.dart'; import 'package:komet/frontend/widgets/sliding_pill_nav.dart'; import 'package:komet/l10n/app_localizations.dart'; +import '../small_spinner.dart'; + const int _navItemCount = 5; List _buildNavItems(AppLocalizations l10n) => [ @@ -395,7 +397,7 @@ class _AttachmentSheetState extends State { double bottomReserve, ) { if (_loading) { - return Center(child: CircularProgressIndicator(color: cs.primary)); + return Center(child: SmallSpinner(size: 36, color: cs.primary)); } if (_permission == GalleryPermission.denied) { return _buildDenied(scrollController, cs, bottomReserve); diff --git a/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart b/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart index 30b59e4..e29850b 100644 --- a/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/video_note_bubble.dart @@ -10,6 +10,7 @@ import '../../../../core/utils/haptics.dart'; import '../../../../core/utils/logger.dart'; import '../../../../core/utils/media_cache.dart'; import '../../../../models/attachment.dart'; +import '../../small_spinner.dart'; class VideoNoteBubble extends StatefulWidget { final VideoAttachment attachment; @@ -182,10 +183,7 @@ class _VideoNoteBubbleState extends State { child: _loading ? const Padding( padding: EdgeInsets.all(14), - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), + child: SmallSpinner(size: 36, color: Colors.white), ) : Icon( _error ? Symbols.error : Symbols.play_arrow, diff --git a/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart b/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart index db11091..f08fd4e 100644 --- a/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart +++ b/lib/frontend/widgets/attachment/bubbles/voice_bubble.dart @@ -13,6 +13,7 @@ import '../../../../core/utils/format.dart'; import '../../../../core/utils/logger.dart'; import '../../../../core/utils/media_cache.dart'; import '../../custom_notification.dart'; +import '../../small_spinner.dart'; class VoiceMessageBubble extends StatefulWidget { final int duration; @@ -231,8 +232,8 @@ class _VoiceMessageBubbleState extends State { child: _loadingAudio ? Padding( padding: const EdgeInsets.all(8), - child: CircularProgressIndicator( - strokeWidth: 2, + child: SmallSpinner( + size: 36, color: widget.isMe ? widget.cs.onPrimaryContainer : widget.cs.primary, @@ -273,13 +274,9 @@ class _VoiceMessageBubbleState extends State { height: 32, child: Center( child: _transcriptionLoading - ? SizedBox( - width: 12, - height: 12, - child: CircularProgressIndicator( - strokeWidth: 1.5, - color: widget.textColor.withValues(alpha: 0.6), - ), + ? SmallSpinner( + size: 12, + color: widget.textColor.withValues(alpha: 0.6), ) : Text( 'Т', diff --git a/lib/frontend/widgets/attachment/media_preview_screen.dart b/lib/frontend/widgets/attachment/media_preview_screen.dart index bc8dfe9..b471628 100644 --- a/lib/frontend/widgets/attachment/media_preview_screen.dart +++ b/lib/frontend/widgets/attachment/media_preview_screen.dart @@ -10,6 +10,7 @@ import 'package:komet/frontend/widgets/attachment/photo_editor.dart'; import 'package:komet/frontend/widgets/custom_notification.dart'; import '../../../core/config/app_colors.dart'; +import '../small_spinner.dart'; const Color _kBar = Color(0xFF1E1E1E); @@ -218,11 +219,7 @@ class _MediaPreviewScreenState extends State { Widget _buildImage() { final file = _workingFile; if (file == null) { - return const SizedBox( - width: 36, - height: 36, - child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white24), - ); + return const SmallSpinner(size: 36, color: Colors.white24); } return Image.file(file, fit: BoxFit.contain, gaplessPlayback: true); } diff --git a/lib/frontend/widgets/attachment/photo_editor.dart b/lib/frontend/widgets/attachment/photo_editor.dart index d5a4fb8..e238a81 100644 --- a/lib/frontend/widgets/attachment/photo_editor.dart +++ b/lib/frontend/widgets/attachment/photo_editor.dart @@ -405,7 +405,7 @@ class _PhotoCropEditorState extends State { final img = _image; if (img == null) { return const Center( - child: CircularProgressIndicator(color: Colors.white), + child: SmallSpinner(size: 36, color: Colors.white), ); } return LayoutBuilder( @@ -2350,7 +2350,7 @@ class _PhotoAdjustEditorState extends State { final img = _image; if (img == null) { return const Center( - child: CircularProgressIndicator(color: Colors.white), + child: SmallSpinner(size: 36, color: Colors.white), ); } return LayoutBuilder( diff --git a/lib/frontend/widgets/avatar_history_screen.dart b/lib/frontend/widgets/avatar_history_screen.dart index 1f57b89..4fa47b4 100644 --- a/lib/frontend/widgets/avatar_history_screen.dart +++ b/lib/frontend/widgets/avatar_history_screen.dart @@ -6,6 +6,7 @@ import '../../backend/modules/contacts.dart'; import '../../core/utils/media_saver.dart'; import '../../main.dart'; import 'custom_notification.dart'; +import 'small_spinner.dart'; class AvatarHistoryScreen extends StatefulWidget { final int contactId; @@ -210,14 +211,7 @@ class _AvatarHistoryScreenState extends State { Expanded(child: _buildCounter()), IconButton( icon: _saving - ? const SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator( - strokeWidth: 2.2, - color: Colors.white, - ), - ) + ? const SmallSpinner(size: 22, color: Colors.white) : const Icon(Symbols.download, color: Colors.white), onPressed: _pages.isEmpty || _saving ? null : _save, ), @@ -276,7 +270,7 @@ class _AvatarHistoryScreenState extends State { if (_pages.isEmpty) { return Center( child: _loading - ? const CircularProgressIndicator(color: Colors.white) + ? const SmallSpinner(size: 36, color: Colors.white) : const Text( 'Нет фотографий', style: TextStyle(color: Colors.white54, fontSize: 15), @@ -293,7 +287,7 @@ class _AvatarHistoryScreenState extends State { fit: BoxFit.contain, fadeInDuration: const Duration(milliseconds: 120), placeholder: (_, _) => const Center( - child: CircularProgressIndicator(color: Colors.white), + child: SmallSpinner(size: 36, color: Colors.white), ), errorWidget: (_, _, _) => const Icon(Symbols.broken_image, color: Colors.white54, size: 64), diff --git a/lib/frontend/widgets/chat_info/shared_content_tabs.dart b/lib/frontend/widgets/chat_info/shared_content_tabs.dart index 3d042ed..d1aaa44 100644 --- a/lib/frontend/widgets/chat_info/shared_content_tabs.dart +++ b/lib/frontend/widgets/chat_info/shared_content_tabs.dart @@ -23,6 +23,7 @@ import '../../screens/chats/chat_screen.dart'; import '../custom_notification.dart'; import '../komet_avatar.dart'; import '../photo_viewer.dart'; +import '../small_spinner.dart'; import '../swipe_route.dart'; import '../video_player_screen.dart'; @@ -103,11 +104,7 @@ Widget _loadingState(ColorScheme cs) { return Padding( padding: const EdgeInsets.symmetric(vertical: 56), child: Center( - child: SizedBox( - width: 26, - height: 26, - child: CircularProgressIndicator(strokeWidth: 2.5, color: cs.primary), - ), + child: SmallSpinner(size: 26, color: cs.primary), ), ); } @@ -611,14 +608,7 @@ class _SharedMediaTabState extends State { padding: const EdgeInsets.symmetric(vertical: 12), child: Center( child: _loadingMore - ? SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator( - strokeWidth: 2.2, - color: cs.primary, - ), - ) + ? SmallSpinner(size: 22, color: cs.primary) : TextButton( onPressed: _loadMore, child: Text(l10n.sharedLoadMore), @@ -1174,10 +1164,7 @@ class _ProfileVoiceTileState extends State<_ProfileVoiceTile> { child: _loadingAudio ? const Padding( padding: EdgeInsets.all(13), - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), + child: SmallSpinner(size: 36, color: Colors.white), ) : ValueListenableBuilder( valueListenable: _progress, diff --git a/lib/frontend/widgets/connection_status.dart b/lib/frontend/widgets/connection_status.dart index c1ebfef..4aacce2 100644 --- a/lib/frontend/widgets/connection_status.dart +++ b/lib/frontend/widgets/connection_status.dart @@ -5,6 +5,7 @@ import 'package:m3e_collection/m3e_collection.dart'; import '../../backend/api.dart'; import '../../main.dart' show api; +import 'small_spinner.dart'; final ValueNotifier debugForceOffline = ValueNotifier(false); @@ -175,14 +176,7 @@ class ConnectionSpinner extends StatelessWidget { ], ), child: Center( - child: SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2.4, - color: cs.primary, - ), - ), + child: SmallSpinner(size: 20, color: cs.primary), ), ), ), diff --git a/lib/frontend/widgets/message_actions_overlay.dart b/lib/frontend/widgets/message_actions_overlay.dart index 8a7b7bc..62609a6 100644 --- a/lib/frontend/widgets/message_actions_overlay.dart +++ b/lib/frontend/widgets/message_actions_overlay.dart @@ -14,6 +14,7 @@ import '../../l10n/app_localizations.dart'; import 'custom_notification.dart'; import 'komet_avatar.dart'; import 'lottie_image.dart'; +import 'small_spinner.dart'; class ReactionEmoji { final String emoji; @@ -1227,11 +1228,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> body = const Padding( padding: EdgeInsets.symmetric(vertical: 28), child: Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2.4), - ), + child: SmallSpinner(size: 24), ), ); } else { @@ -1314,11 +1311,7 @@ class _MessageActionsLayerState extends State<_MessageActionsLayer> body = const Padding( padding: EdgeInsets.symmetric(vertical: 28), child: Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2.4), - ), + child: SmallSpinner(size: 24), ), ); } else { @@ -1647,11 +1640,7 @@ class _ReactionEmojiPickerState extends State<_ReactionEmojiPicker> { Expanded( child: !_loaded ? const Center( - child: SizedBox( - width: 26, - height: 26, - child: CircularProgressIndicator(strokeWidth: 2.4), - ), + child: SmallSpinner(size: 26), ) : _results.isEmpty ? const SizedBox.shrink() diff --git a/lib/frontend/widgets/photo_viewer.dart b/lib/frontend/widgets/photo_viewer.dart index 5fa3260..9af43f5 100644 --- a/lib/frontend/widgets/photo_viewer.dart +++ b/lib/frontend/widgets/photo_viewer.dart @@ -21,6 +21,7 @@ import '../../main.dart'; import '../../models/attachment.dart'; import 'chat_menu_overlay.dart'; import 'custom_notification.dart'; +import 'small_spinner.dart'; class PhotoViewerActions { final void Function(String messageId, int time)? goToMessage; @@ -550,14 +551,7 @@ class _PhotoViewerScreenState extends State { Expanded(child: _buildInfo(l10n)), IconButton( icon: _saving - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) + ? const SmallSpinner(size: 20, color: Colors.white) : const Icon(Symbols.download, color: Colors.white), onPressed: _saving ? null : _save, tooltip: l10n.sharedDownload, @@ -666,7 +660,7 @@ class _PhotoViewerScreenState extends State { fit: BoxFit.contain, fadeInDuration: const Duration(milliseconds: 120), placeholder: (_, _) => - const Center(child: CircularProgressIndicator(color: Colors.white)), + const Center(child: SmallSpinner(size: 36, color: Colors.white)), errorWidget: (_, _, _) => _broken(), ); } diff --git a/lib/frontend/widgets/poll_view.dart b/lib/frontend/widgets/poll_view.dart index 6f0d2c8..b68cf62 100644 --- a/lib/frontend/widgets/poll_view.dart +++ b/lib/frontend/widgets/poll_view.dart @@ -6,6 +6,7 @@ import '../../core/utils/format.dart'; import '../../core/utils/haptics.dart'; import '../../models/poll.dart'; import 'custom_notification.dart'; +import 'small_spinner.dart'; class PollView extends StatefulWidget { final int chatId; @@ -222,14 +223,7 @@ class _PollViewState extends State ), ), if (_voting && !multiple) - SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator( - strokeWidth: 1.5, - color: widget.dimColor, - ), - ), + SmallSpinner(size: 14, color: widget.dimColor), ], ), ), @@ -253,14 +247,7 @@ class _PollViewState extends State ), ), child: _voting - ? SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: widget.accentColor, - ), - ) + ? SmallSpinner(size: 16, color: widget.accentColor) : const Text('Проголосовать'), ), ), @@ -355,12 +342,14 @@ class _PollViewState extends State padding: const EdgeInsets.only(top: 6), child: ClipRRect( borderRadius: BorderRadius.circular(4), - child: LinearProgressIndicator( - value: fillFactor, - minHeight: 6, - backgroundColor: widget.dimColor.withValues(alpha: 0.2), - valueColor: AlwaysStoppedAnimation( - widget.accentColor, + child: Container( + height: 6, + color: widget.dimColor.withValues(alpha: 0.2), + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: fillFactor.clamp(0.0, 1.0), + heightFactor: 1, + child: ColoredBox(color: widget.accentColor), ), ), ), diff --git a/lib/frontend/widgets/primary_loading_button.dart b/lib/frontend/widgets/primary_loading_button.dart index 64803fb..7581d1c 100644 --- a/lib/frontend/widgets/primary_loading_button.dart +++ b/lib/frontend/widgets/primary_loading_button.dart @@ -1,6 +1,9 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; +import 'small_spinner.dart'; +import 'springy_tap.dart'; + class PrimaryLoadingButton extends StatelessWidget { final ValueListenable loading; final VoidCallback? onPressed; @@ -23,23 +26,22 @@ class PrimaryLoadingButton extends StatelessWidget { final fg = foreground ?? cs.onPrimary; return ValueListenableBuilder( valueListenable: loading, - builder: (context, isLoading, _) => FilledButton( - onPressed: isLoading ? null : onPressed, - style: FilledButton.styleFrom( - backgroundColor: background ?? cs.primary, - foregroundColor: fg, - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + builder: (context, isLoading, _) => SpringyTap( + enabled: !isLoading, + child: FilledButton( + onPressed: isLoading ? null : onPressed, + style: FilledButton.styleFrom( + backgroundColor: background ?? cs.primary, + foregroundColor: fg, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), ), + child: isLoading + ? SmallSpinner(size: 20, color: fg) + : child, ), - child: isLoading - ? SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2, color: fg), - ) - : child, ), ); } diff --git a/lib/frontend/widgets/small_spinner.dart b/lib/frontend/widgets/small_spinner.dart index 0509e3d..d2979dc 100644 --- a/lib/frontend/widgets/small_spinner.dart +++ b/lib/frontend/widgets/small_spinner.dart @@ -1,14 +1,13 @@ import 'package:flutter/material.dart'; +import 'package:m3e_collection/m3e_collection.dart'; class SmallSpinner extends StatelessWidget { final double size; - final double strokeWidth; final Color? color; const SmallSpinner({ super.key, this.size = 26, - this.strokeWidth = 2.4, this.color, }); @@ -17,9 +16,12 @@ class SmallSpinner extends StatelessWidget { return SizedBox( width: size, height: size, - child: CircularProgressIndicator( - strokeWidth: strokeWidth, - color: color ?? Theme.of(context).colorScheme.primary, + child: FittedBox( + fit: BoxFit.contain, + child: ExpressiveLoadingIndicator( + color: color ?? Theme.of(context).colorScheme.primary, + constraints: BoxConstraints.tight(const Size.square(48)), + ), ), ); } @@ -33,7 +35,7 @@ class BusyOverlay extends StatelessWidget { return const Positioned.fill( child: ColoredBox( color: Colors.black54, - child: Center(child: CircularProgressIndicator(color: Colors.white)), + child: Center(child: SmallSpinner(size: 44, color: Colors.white)), ), ); } diff --git a/lib/frontend/widgets/springy_tap.dart b/lib/frontend/widgets/springy_tap.dart new file mode 100644 index 0000000..ca4f639 --- /dev/null +++ b/lib/frontend/widgets/springy_tap.dart @@ -0,0 +1,108 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; + +class SpringyTap extends StatefulWidget { + final Widget child; + final double pressedScale; + final bool enabled; + + const SpringyTap({ + super.key, + required this.child, + this.pressedScale = 0.98, + this.enabled = true, + }); + + @override + State createState() => _SpringyTapState(); +} + +class _SpringyTapState extends State + with SingleTickerProviderStateMixin { + static const Duration _pressDelay = Duration(milliseconds: 60); + static const Duration _pressDuration = Duration(milliseconds: 90); + + static final SpringDescription _spring = SpringDescription.withDampingRatio( + ratio: 0.8, + stiffness: 350, + mass: 1, + ); + + late final AnimationController _controller = AnimationController.unbounded( + vsync: this, + value: 1.0, + ); + + Timer? _pressTimer; + + @override + void dispose() { + _pressTimer?.cancel(); + _controller.dispose(); + super.dispose(); + } + + void _press() { + _controller.stop(); + _controller.animateTo( + widget.pressedScale, + duration: _pressDuration, + curve: Curves.easeOut, + ); + } + + void _release() { + if (_controller.value == 1.0 && !_controller.isAnimating) return; + _controller.animateWith( + SpringSimulation(_spring, _controller.value, 1.0, 1.5), + ); + } + + Future _pulse() async { + _controller.stop(); + await _controller.animateTo( + widget.pressedScale, + duration: _pressDuration, + curve: Curves.easeOut, + ); + if (mounted) _release(); + } + + void _onDown(PointerDownEvent _) { + _pressTimer?.cancel(); + _pressTimer = Timer(_pressDelay, () { + _pressTimer = null; + _press(); + }); + } + + void _onUp(PointerUpEvent _) { + if (_pressTimer != null) { + _pressTimer!.cancel(); + _pressTimer = null; + unawaited(_pulse()); + } else { + _release(); + } + } + + void _onCancel(PointerCancelEvent _) { + _pressTimer?.cancel(); + _pressTimer = null; + _release(); + } + + @override + Widget build(BuildContext context) { + if (!widget.enabled) return widget.child; + return Listener( + behavior: HitTestBehavior.deferToChild, + onPointerDown: _onDown, + onPointerUp: _onUp, + onPointerCancel: _onCancel, + child: ScaleTransition(scale: _controller, child: widget.child), + ); + } +} diff --git a/lib/frontend/widgets/sticker_pack_sheet.dart b/lib/frontend/widgets/sticker_pack_sheet.dart index c727ae1..eae24c4 100644 --- a/lib/frontend/widgets/sticker_pack_sheet.dart +++ b/lib/frontend/widgets/sticker_pack_sheet.dart @@ -310,13 +310,9 @@ class _StickerPackSheetState extends State<_StickerPackSheet> { ), onPressed: _busy ? null : _toggle, child: _busy - ? SizedBox( - width: 22, - height: 22, - child: CircularProgressIndicator( - strokeWidth: 2.4, - color: _isFavorite ? cs.onSurface : cs.onPrimary, - ), + ? SmallSpinner( + size: 22, + color: _isFavorite ? cs.onSurface : cs.onPrimary, ) : Text( _isFavorite ? 'Убрать' : 'Добавить', diff --git a/lib/frontend/widgets/video_player_screen.dart b/lib/frontend/widgets/video_player_screen.dart index 6ed451a..26263db 100644 --- a/lib/frontend/widgets/video_player_screen.dart +++ b/lib/frontend/widgets/video_player_screen.dart @@ -3,6 +3,7 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:video_player/video_player.dart'; import '../../core/utils/format.dart'; +import 'small_spinner.dart'; class VideoPlayerScreen extends StatefulWidget { final Map sources; @@ -133,11 +134,11 @@ class _VideoPlayerScreenState extends State { aspectRatio: c.value.aspectRatio, child: VideoPlayer(c), ) - : const CircularProgressIndicator(color: Colors.white), + : const SmallSpinner(size: 36, color: Colors.white), ), if (buffering) const Center( - child: CircularProgressIndicator(color: Colors.white), + child: SmallSpinner(size: 36, color: Colors.white), ), if (!_error) AnimatedOpacity( diff --git a/lib/frontend/widgets/web_qr_login.dart b/lib/frontend/widgets/web_qr_login.dart index 96feca7..d216afd 100644 --- a/lib/frontend/widgets/web_qr_login.dart +++ b/lib/frontend/widgets/web_qr_login.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../main.dart' show accountModule; import 'custom_notification.dart'; import 'sheet_helpers.dart'; +import 'small_spinner.dart'; Future showWebQrLoginConfirmSheet(BuildContext context) async { final agreed = await showModalBottomSheet( @@ -88,7 +89,7 @@ Future confirmAndAuthorizeWebQrLogin( color: cs.surfaceContainerHigh, child: const Padding( padding: EdgeInsets.all(28), - child: CircularProgressIndicator(), + child: SmallSpinner(size: 36), ), ), ), diff --git a/lib/main.dart b/lib/main.dart index d6ad510..a583f13 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -80,6 +80,7 @@ import 'frontend/screens/auth/login_screen.dart'; import 'frontend/widgets/adaptive_shell.dart'; import 'frontend/widgets/custom_notification.dart'; import 'frontend/widgets/liquid_glass.dart'; +import 'frontend/widgets/small_spinner.dart'; import 'frontend/widgets/theme_reveal.dart'; final api = Api(); @@ -98,6 +99,9 @@ final RouteObserver> appRouteObserver = bool isOnemeFlavor = false; +const ProgressIndicatorThemeData _expressiveProgressTheme = + ProgressIndicatorThemeData(year2023: false); + const PageTransitionsTheme _appPageTransitions = PageTransitionsTheme( builders: { TargetPlatform.android: PredictiveBackPageTransitionsBuilder(), @@ -835,6 +839,7 @@ class KometAppState extends State useMaterial3: true, colorScheme: light, pageTransitionsTheme: _appPageTransitions, + progressIndicatorTheme: _expressiveProgressTheme, textTheme: AppFonts.textTheme( _fontId, ThemeData(brightness: Brightness.light).textTheme, @@ -846,6 +851,7 @@ class KometAppState extends State useMaterial3: true, colorScheme: dark, pageTransitionsTheme: _appPageTransitions, + progressIndicatorTheme: _expressiveProgressTheme, textTheme: AppFonts.textTheme( _fontId, ThemeData(brightness: Brightness.dark).textTheme, @@ -1064,7 +1070,7 @@ class _StartupScreenState extends State<_StartupScreen> { return Scaffold( backgroundColor: cs.surface, body: Center( - child: CircularProgressIndicator(color: cs.primary, strokeWidth: 2), + child: SmallSpinner(size: 36, color: cs.primary), ), ); } diff --git a/macos/Podfile.lock b/macos/Podfile.lock index b5a901b..ec4ddd1 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -262,7 +262,7 @@ SPEC CHECKSUMS: photo_manager: 25fd77df14f4f0ba5ef99e2c61814dde77e2bceb PromisesObjC: 752c3227f599e3467650e47ea36f433eeb10c273 record_macos: 5d55909f9650314be6424ffd6b123ac75a08c3c1 - rlottie: c2780aabf858ca3c81785f09256827ca83b0765d + rlottie: 206daeeeb0f9dec6594ed248eb0c7e876a289e93 share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0