diff --git a/lib/home_screen.dart b/lib/home_screen.dart index a0aee1c..2fd13da 100644 --- a/lib/home_screen.dart +++ b/lib/home_screen.dart @@ -259,8 +259,26 @@ class _HomeScreenState extends State { } void _handleJoinLink(Uri uri) { - if (uri.host == 'max.ru' && uri.path.startsWith('/join/')) { - final String fullLink = uri.toString(); + if (uri.host != 'max.ru') return; + + String fullLink = uri.toString(); + + // На всякий случай убираем возможный префикс '@' перед ссылкой + if (fullLink.startsWith('@')) { + fullLink = fullLink.substring(1); + } + + final bool isGroupLink = uri.path.startsWith('/join/'); + final bool isChannelLink = + !isGroupLink && + uri.pathSegments.isNotEmpty && + uri.pathSegments.first.startsWith('id'); + + if (!isGroupLink && !isChannelLink) { + return; + } + + if (isGroupLink) { final String processedLink = _extractJoinLink(fullLink); if (!processedLink.contains('join/')) { @@ -309,6 +327,39 @@ class _HomeScreenState extends State { } }); }); + } else if (isChannelLink) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Загрузка информации о канале...'), + behavior: SnackBarBehavior.floating, + margin: EdgeInsets.all(10), + duration: Duration(seconds: 10), + ), + ); + + ApiService.instance.waitUntilOnline().then((_) { + ApiService.instance + .getChatInfoByLink(fullLink) + .then((chatInfo) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + if (mounted) { + _showChannelSubscribeDialog(chatInfo, fullLink); + } + }) + .catchError((error) { + ScaffoldMessenger.of(context).hideCurrentSnackBar(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка: ${error.toString()}'), + backgroundColor: Theme.of(context).colorScheme.error, + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + } + }); + }); } } @@ -530,6 +581,227 @@ class _HomeScreenState extends State { ); } + void _showChannelSubscribeDialog( + Map chatInfo, + String linkToJoin, + ) { + final String title = chatInfo['title'] ?? 'Канал'; + final String? iconUrl = + chatInfo['baseIconUrl'] ?? chatInfo['baseUrl'] ?? chatInfo['iconUrl']; + + int subscribeState = 0; + String? errorMessage; + + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return StatefulBuilder( + builder: (context, setState) { + Widget content; + List actions = []; + + if (subscribeState == 1) { + content = Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 32), + const CircularProgressIndicator(), + const SizedBox(height: 24), + Text( + 'Подписка...', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + ], + ); + actions = []; + } else if (subscribeState == 2) { + content = Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 32), + const Icon( + Icons.check_circle_outline, + color: Colors.green, + size: 60, + ), + const SizedBox(height: 24), + Text( + 'Вы подписались на канал!', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + ], + ); + actions = [ + FilledButton( + child: const Text('Отлично'), + onPressed: () { + Navigator.of(dialogContext).pop(); + }, + ), + ]; + } else if (subscribeState == 3) { + content = Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 32), + Icon( + Icons.error_outline, + color: Theme.of(context).colorScheme.error, + size: 60, + ), + const SizedBox(height: 24), + Text( + 'Ошибка', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + errorMessage ?? 'Не удалось подписаться на канал.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 32), + ], + ); + actions = [ + TextButton( + child: const Text('Закрыть'), + onPressed: () { + Navigator.of(dialogContext).pop(); + }, + ), + ]; + } else { + content = Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (iconUrl != null && iconUrl.isNotEmpty) + CircleAvatar( + radius: 60, + backgroundImage: NetworkImage(iconUrl), + onBackgroundImageError: (e, s) { + print("Ошибка загрузки аватара канала: $e"); + }, + backgroundColor: Colors.grey.shade300, + ) + else + CircleAvatar( + radius: 60, + backgroundColor: Colors.grey.shade300, + child: const Icon( + Icons.campaign, + size: 60, + color: Colors.white, + ), + ), + const SizedBox(height: 24), + Text( + title, + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + const Text( + 'Вы действительно хотите подписаться на этот канал?', + textAlign: TextAlign.center, + ), + ], + ); + actions = [ + TextButton( + child: const Text('Отмена'), + onPressed: () { + Navigator.of(dialogContext).pop(); + }, + ), + FilledButton( + child: const Text('Подписаться'), + onPressed: () async { + setState(() { + subscribeState = 1; + }); + + try { + await ApiService.instance.subscribeToChannel(linkToJoin); + + setState(() { + subscribeState = 2; + }); + + ApiService.instance.clearChatsCache(); + + await Future.delayed(const Duration(seconds: 2)); + if (mounted) { + Navigator.of(dialogContext).pop(); + } + } catch (e) { + setState(() { + subscribeState = 3; + errorMessage = e.toString().replaceFirst( + "Exception: ", + "", + ); + }); + } + }, + ), + ]; + } + + return AlertDialog( + title: subscribeState == 0 + ? const Text('Подписаться на канал?') + : null, + content: AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + transitionBuilder: + (Widget child, Animation animation) { + final slideAnimation = + Tween( + begin: const Offset(0, 0.2), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: animation, + curve: Curves.easeOutQuart, + ), + ); + + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: slideAnimation, + child: child, + ), + ); + }, + child: Container( + key: ValueKey(subscribeState), + child: content, + ), + ), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + actionsAlignment: MainAxisAlignment.center, + actions: actions, + ); + }, + ); + }, + ); + } + String _extractJoinLink(String inputLink) { final link = inputLink.trim(); diff --git a/lib/join_group_screen.dart b/lib/join_group_screen.dart index 319bfeb..2ea4f94 100644 --- a/lib/join_group_screen.dart +++ b/lib/join_group_screen.dart @@ -1,5 +1,3 @@ - - import 'dart:async'; import 'package:flutter/material.dart'; import 'package:gwid/api/api_service.dart'; @@ -33,7 +31,6 @@ class _JoinGroupScreenState extends State { _apiSubscription = ApiService.instance.messages.listen((message) { if (!mounted) return; - if (message['type'] == 'group_join_success') { setState(() { _isLoading = false; @@ -55,11 +52,9 @@ class _JoinGroupScreenState extends State { ), ); - Navigator.of(context).pop(); } - if (message['cmd'] == 3 && message['opcode'] == 57) { setState(() { _isLoading = false; @@ -90,17 +85,25 @@ class _JoinGroupScreenState extends State { }); } + String _normalizeLink(String inputLink) { + String link = inputLink.trim(); + + // Поддержка формата @https://max.ru/... + if (link.startsWith('@')) { + link = link.substring(1).trim(); + } + + return link; + } String _extractJoinLink(String inputLink) { - final link = inputLink.trim(); - + final link = _normalizeLink(inputLink); if (link.startsWith('join/')) { print('Ссылка уже в правильном формате: $link'); return link; } - final joinIndex = link.indexOf('join/'); if (joinIndex != -1) { final extractedLink = link.substring(joinIndex); @@ -108,18 +111,33 @@ class _JoinGroupScreenState extends State { return extractedLink; } - print('Не найдено "join/" в ссылке: $link'); return link; } + bool _isChannelLink(String inputLink) { + final link = _normalizeLink(inputLink); + + try { + final uri = Uri.parse(link); + if (uri.host == 'max.ru' && + uri.pathSegments.isNotEmpty && + uri.pathSegments.first.startsWith('id')) { + return true; + } + } catch (_) {} + + return false; + } + void _joinGroup() async { - final inputLink = _linkController.text.trim(); + final rawInput = _linkController.text.trim(); + final inputLink = _normalizeLink(rawInput); if (inputLink.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: const Text('Введите ссылку на группу'), + content: const Text('Введите ссылку'), backgroundColor: Colors.orange, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), @@ -131,15 +149,49 @@ class _JoinGroupScreenState extends State { return; } + // Сначала пытаемся распознать ссылку на канал (https://max.ru/id...) + if (_isChannelLink(inputLink)) { + setState(() { + _isLoading = true; + }); + try { + final chatInfo = await ApiService.instance.getChatInfoByLink(inputLink); + if (!mounted) return; + setState(() { + _isLoading = false; + }); + + // Показываем диалог подписки на канал + _showChannelSubscribeDialog(chatInfo, inputLink); + } catch (e) { + if (!mounted) return; + setState(() { + _isLoading = false; + }); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Ошибка: ${e.toString()}'), + backgroundColor: Theme.of(context).colorScheme.error, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + behavior: SnackBarBehavior.floating, + margin: const EdgeInsets.all(10), + ), + ); + } + return; + } + + // Иначе считаем, что это ссылка на группу с "join/" final processedLink = _extractJoinLink(inputLink); - if (!processedLink.contains('join/')) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text( - 'Неверный формат ссылки. Ссылка должна содержать "join/"', + 'Неверный формат ссылки. Для группы ссылка должна содержать "join/"', ), backgroundColor: Colors.orange, shape: RoundedRectangleBorder( @@ -164,7 +216,7 @@ class _JoinGroupScreenState extends State { }); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('Ошибка присоединения к группе: ${e.toString()}'), + content: Text('Ошибка присоединения: ${e.toString()}'), backgroundColor: Theme.of(context).colorScheme.error, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), @@ -182,7 +234,7 @@ class _JoinGroupScreenState extends State { return Scaffold( appBar: AppBar( - title: const Text('Присоединиться к группе'), + title: const Text('Присоединиться по ссылке'), backgroundColor: colors.surface, foregroundColor: colors.onSurface, ), @@ -193,7 +245,6 @@ class _JoinGroupScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -205,10 +256,10 @@ class _JoinGroupScreenState extends State { children: [ Row( children: [ - Icon(Icons.group_add, color: colors.primary), + Icon(Icons.link, color: colors.primary), const SizedBox(width: 8), Text( - 'Присоединение к группе', + 'Присоединение по ссылке', style: TextStyle( fontWeight: FontWeight.bold, color: colors.primary, @@ -218,9 +269,10 @@ class _JoinGroupScreenState extends State { ), const SizedBox(height: 8), Text( - 'Введите ссылку на группу, чтобы присоединиться к ней. ' - 'Можно вводить как полную ссылку (https://max.ru/join/...), ' - 'так и короткую (join/...).', + 'Введите ссылку на группу или канал, чтобы присоединиться. ' + 'Для групп можно вводить полную (https://max.ru/join/...) ' + 'или короткую (join/...) ссылку, для каналов — ссылку вида ' + 'https://max.ru/idXXXXXXXX.', style: TextStyle(color: colors.onSurfaceVariant), ), ], @@ -229,9 +281,8 @@ class _JoinGroupScreenState extends State { const SizedBox(height: 24), - Text( - 'Ссылка на группу', + 'Ссылка', style: Theme.of( context, ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), @@ -241,8 +292,9 @@ class _JoinGroupScreenState extends State { TextField( controller: _linkController, decoration: InputDecoration( - labelText: 'Ссылка на группу', - hintText: 'https://max.ru/join/ABC123DEF456GHI789JKL', + labelText: 'Ссылка на группу или канал', + hintText: + 'https://max.ru/join/ABC123DEF456GHI789JKL или https://max.ru/id7452017130_gos', border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), ), @@ -281,11 +333,12 @@ class _JoinGroupScreenState extends State { ), const SizedBox(height: 8), Text( - '• Ссылка должна содержать "join/"\n' + '• Для групп: ссылка должна содержать "join/"\n' '• После "join/" должен идти уникальный идентификатор группы\n' - '• Примеры:\n' + '• Примеры групп:\n' ' - https://max.ru/join/ABC123DEF456GHI789JKL\n' - ' - join/ABC123DEF456GHI789JKL', + ' - join/ABC123DEF456GHI789JKL\n' + '• Для каналов: ссылка вида https://max.ru/idXXXXXXXX', style: TextStyle( color: colors.onSurfaceVariant, fontSize: 13, @@ -312,11 +365,11 @@ class _JoinGroupScreenState extends State { ), ), ) - : const Icon(Icons.group_add), + : const Icon(Icons.link), label: Text( _isLoading ? 'Присоединение...' - : 'Присоединиться к группе', + : 'Присоединиться по ссылке', ), style: ElevatedButton.styleFrom( minimumSize: const Size(double.infinity, 50), @@ -338,4 +391,223 @@ class _JoinGroupScreenState extends State { ), ); } + + void _showChannelSubscribeDialog( + Map chatInfo, + String linkToJoin, + ) { + final String title = chatInfo['title'] ?? 'Канал'; + final String? iconUrl = + chatInfo['baseIconUrl'] ?? chatInfo['baseUrl'] ?? chatInfo['iconUrl']; + + int subscribeState = 0; + String? errorMessage; + + showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) { + return StatefulBuilder( + builder: (context, setState) { + Widget content; + List actions = []; + + if (subscribeState == 1) { + content = Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 32), + const CircularProgressIndicator(), + const SizedBox(height: 24), + Text( + 'Подписка...', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + ], + ); + actions = []; + } else if (subscribeState == 2) { + content = Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 32), + const Icon( + Icons.check_circle_outline, + color: Colors.green, + size: 60, + ), + const SizedBox(height: 24), + Text( + 'Вы подписались на канал!', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + ], + ); + actions = [ + FilledButton( + child: const Text('Отлично'), + onPressed: () { + Navigator.of(dialogContext).pop(); + }, + ), + ]; + } else if (subscribeState == 3) { + content = Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 32), + Icon( + Icons.error_outline, + color: Theme.of(context).colorScheme.error, + size: 60, + ), + const SizedBox(height: 24), + Text( + 'Ошибка', + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + errorMessage ?? 'Не удалось подписаться на канал.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 32), + ], + ); + actions = [ + TextButton( + child: const Text('Закрыть'), + onPressed: () { + Navigator.of(dialogContext).pop(); + }, + ), + ]; + } else { + content = Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (iconUrl != null && iconUrl.isNotEmpty) + CircleAvatar( + radius: 60, + backgroundImage: NetworkImage(iconUrl), + onBackgroundImageError: (e, s) { + print("Ошибка загрузки аватара канала: $e"); + }, + backgroundColor: Colors.grey.shade300, + ) + else + CircleAvatar( + radius: 60, + backgroundColor: Colors.grey.shade300, + child: const Icon( + Icons.campaign, + size: 60, + color: Colors.white, + ), + ), + const SizedBox(height: 24), + Text( + title, + style: Theme.of(context).textTheme.titleLarge, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + const Text( + 'Вы действительно хотите подписаться на этот канал?', + textAlign: TextAlign.center, + ), + ], + ); + actions = [ + TextButton( + child: const Text('Отмена'), + onPressed: () { + Navigator.of(dialogContext).pop(); + }, + ), + FilledButton( + child: const Text('Подписаться'), + onPressed: () async { + setState(() { + subscribeState = 1; + }); + + try { + await ApiService.instance.subscribeToChannel(linkToJoin); + + setState(() { + subscribeState = 2; + }); + + await Future.delayed(const Duration(seconds: 2)); + if (mounted) { + Navigator.of(dialogContext).pop(); + } + } catch (e) { + setState(() { + subscribeState = 3; + errorMessage = e.toString().replaceFirst( + "Exception: ", + "", + ); + }); + } + }, + ), + ]; + } + + return AlertDialog( + title: subscribeState == 0 + ? const Text('Подписаться на канал?') + : null, + content: AnimatedSize( + duration: const Duration(milliseconds: 250), + curve: Curves.easeInOut, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 250), + transitionBuilder: + (Widget child, Animation animation) { + final slideAnimation = + Tween( + begin: const Offset(0, 0.2), + end: Offset.zero, + ).animate( + CurvedAnimation( + parent: animation, + curve: Curves.easeOutQuart, + ), + ); + + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: slideAnimation, + child: child, + ), + ); + }, + child: Container( + key: ValueKey(subscribeState), + child: content, + ), + ), + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + actionsAlignment: MainAxisAlignment.center, + actions: actions, + ); + }, + ); + }, + ); + } } diff --git a/lib/search_channels_screen.dart b/lib/search_channels_screen.dart index 7fd75a6..4bba36d 100644 --- a/lib/search_channels_screen.dart +++ b/lib/search_channels_screen.dart @@ -1,5 +1,3 @@ - - import 'dart:async'; import 'package:flutter/material.dart'; import 'package:gwid/api/api_service.dart'; @@ -36,7 +34,6 @@ class _SearchChannelsScreenState extends State { _apiSubscription = ApiService.instance.messages.listen((message) { if (!mounted) return; - if (message['type'] == 'channels_found') { setState(() { _isLoading = false; @@ -65,7 +62,6 @@ class _SearchChannelsScreenState extends State { ); } - if (message['type'] == 'channels_not_found') { setState(() { _isLoading = false; @@ -171,7 +167,6 @@ class _SearchChannelsScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( @@ -209,7 +204,6 @@ class _SearchChannelsScreenState extends State { const SizedBox(height: 24), - Text( 'Поисковый запрос', style: Theme.of( @@ -300,7 +294,6 @@ class _SearchChannelsScreenState extends State { ), ), - if (_foundChannels.isNotEmpty) ...[ const SizedBox(height: 24), Text( @@ -315,7 +308,6 @@ class _SearchChannelsScreenState extends State { ), ], - if (_errorMessage != null) ...[ const SizedBox(height: 24), Container( @@ -483,7 +475,6 @@ class _ChannelDetailsScreenState extends State { _apiSubscription = ApiService.instance.messages.listen((message) { if (!mounted) return; - if (message['type'] == 'channel_entered') { setState(() { _isLoading = false; @@ -506,7 +497,6 @@ class _ChannelDetailsScreenState extends State { ); } - if (message['type'] == 'channel_subscribed') { setState(() { _isLoading = false; @@ -525,7 +515,6 @@ class _ChannelDetailsScreenState extends State { ); } - if (message['type'] == 'channel_error') { setState(() { _isLoading = false; @@ -561,16 +550,14 @@ class _ChannelDetailsScreenState extends State { }); } - String _extractChannelLink(String inputLink) { - final link = inputLink.trim(); + String link = inputLink.trim(); - - if (link.startsWith('https://max.ru/') || link.startsWith('max.ru/')) { - return link; + // Поддержка формата @https://max.ru/... + if (link.startsWith('@')) { + link = link.substring(1).trim(); } - return link; } @@ -675,7 +662,6 @@ class _ChannelDetailsScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Card( shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), @@ -772,7 +758,6 @@ class _ChannelDetailsScreenState extends State { const SizedBox(height: 24), - Column( children: [ SizedBox( @@ -820,7 +805,6 @@ class _ChannelDetailsScreenState extends State { ], ), - if (_webAppUrl != null) ...[ const SizedBox(height: 24), Container( @@ -858,7 +842,6 @@ class _ChannelDetailsScreenState extends State { width: double.infinity, child: ElevatedButton.icon( onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: const Text( @@ -889,7 +872,6 @@ class _ChannelDetailsScreenState extends State { ), ], - if (_errorMessage != null) ...[ const SizedBox(height: 24), Container( diff --git a/pubspec.lock b/pubspec.lock index f6829b1..12c9db7 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -809,10 +809,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mime: dependency: transitive description: @@ -1318,10 +1318,10 @@ packages: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" timezone: dependency: "direct main" description: