From e7bb0a629119c408ae63353c92f6cf3cd7ba0087 Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Tue, 25 Aug 2026 21:04:18 +0200 Subject: [PATCH 01/11] Implement monetization banner diagnostics and IRC list polish --- README.md | 4 +- android/app/src/main/AndroidManifest.xml | 17 +- lib/app/app.dart | 102 ++++- lib/core/app/app_version.dart | 4 +- .../application/chat_session_controller.dart | 33 +- .../presentation/channel_list_screen.dart | 29 +- .../chat/presentation/chat_screen.dart | 426 +++++++++--------- .../chat/presentation/irc_formatted_text.dart | 195 ++++++++ .../presentation/monetization_banner.dart | 236 ++++++++++ .../presentation/purchase_screen.dart | 204 +++++++++ .../presentation/data_privacy_screen.dart | 29 +- .../presentation/settings_screen.dart | 175 ++++++- lib/main.dart | 7 + lib/monetization/monetization_config.dart | 116 +++++ lib/monetization/monetization_controller.dart | 272 +++++++++++ lib/monetization/monetization_scope.dart | 36 ++ lib/monetization/rewarded_ad_service.dart | 192 ++++++++ lib/monetization/store_purchase_service.dart | 200 ++++++++ macos/Flutter/GeneratedPluginRegistrant.swift | 4 + pubspec.lock | 72 +++ pubspec.yaml | 4 +- test/chat_session_controller_test.dart | 7 + test/monetization_controller_test.dart | 80 ++++ test/monetization_settings_test.dart | 53 +++ test/widget_test.dart | 142 +++++- 25 files changed, 2349 insertions(+), 290 deletions(-) create mode 100644 lib/features/chat/presentation/irc_formatted_text.dart create mode 100644 lib/features/monetization/presentation/monetization_banner.dart create mode 100644 lib/features/monetization/presentation/purchase_screen.dart create mode 100644 lib/monetization/monetization_config.dart create mode 100644 lib/monetization/monetization_controller.dart create mode 100644 lib/monetization/monetization_scope.dart create mode 100644 lib/monetization/rewarded_ad_service.dart create mode 100644 lib/monetization/store_purchase_service.dart create mode 100644 test/monetization_controller_test.dart create mode 100644 test/monetization_settings_test.dart diff --git a/README.md b/README.md index 243f79c..498942c 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,9 @@ Implemented in the current Flutter app: Still outside the default-client release scope: -- E2EE, ads/IAP, and scripting are later product/security decisions +- E2EE and scripting are later product/security decisions +- ads/IAP are now an explicit monetization slice: top banner ads, opt-in + rewarded ads for temporary banner-free time, and one-time no-ads purchases - WebRTC calling is not planned - upload/share endpoints are deferred until a concrete product endpoint exists diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index ca15fd6..6c7ac3f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,7 +1,9 @@ - + + @@ -10,6 +12,19 @@ android:label="@string/app_name" android:name="${applicationName}" android:icon="@mipmap/ic_launcher"> + + + + createState() => _AndroidIrcxAppState(); @@ -35,6 +47,12 @@ class AndroidIrcxApp extends StatefulWidget { class _AndroidIrcxAppState extends State { late final AppSettingsController _settingsController; + late final MonetizationController _monetizationController; + late final RewardedAdService _rewardedAdService; + late final StorePurchaseService _purchaseService; + late final bool _ownsMonetizationController; + late final bool _ownsRewardedAdService; + late final bool _ownsPurchaseService; bool? _appliedScreenSecure; bool? _appliedAnalyticsConsent; @@ -44,15 +62,37 @@ class _AndroidIrcxAppState extends State { _settingsController = AppSettingsController( repository: widget.settingsRepository, ); + _ownsMonetizationController = widget.monetizationController == null; + _monetizationController = + widget.monetizationController ?? MonetizationController(); + _ownsRewardedAdService = widget.rewardedAdService == null; + _rewardedAdService = + widget.rewardedAdService ?? + RewardedAdService(monetizationController: _monetizationController); + _ownsPurchaseService = widget.purchaseService == null; + _purchaseService = + widget.purchaseService ?? + StorePurchaseService(monetizationController: _monetizationController); _settingsController.addListener(_applySettingsSideEffects); _settingsController.load(); + unawaited(_monetizationController.initialize()); + if (MonetizationConfig.storeRuntimeSupported) { + unawaited(_purchaseService.initialize()); + } + if (MonetizationConfig.mobileAdsRuntimeSupported) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _rewardedAdService.loadAd(); + }); + } } void _applySettingsSideEffects() { final settings = _settingsController.settings; if (settings.screenshotProtection != _appliedScreenSecure) { _appliedScreenSecure = settings.screenshotProtection; - unawaited(const ScreenSecurity().setSecure(settings.screenshotProtection)); + unawaited( + const ScreenSecurity().setSecure(settings.screenshotProtection), + ); } if (settings.analyticsConsent != _appliedAnalyticsConsent) { _appliedAnalyticsConsent = settings.analyticsConsent; @@ -64,37 +104,55 @@ class _AndroidIrcxAppState extends State { void dispose() { _settingsController.removeListener(_applySettingsSideEffects); _settingsController.dispose(); + if (_ownsPurchaseService) { + _purchaseService.dispose(); + } + if (_ownsRewardedAdService) { + _rewardedAdService.dispose(); + } + if (_ownsMonetizationController) { + _monetizationController.dispose(); + } super.dispose(); } @override Widget build(BuildContext context) { - return AppSettingsScope( - controller: _settingsController, - child: AnimatedBuilder( - animation: _settingsController, - builder: (context, _) { - return MaterialApp( - title: 'AndroidIRCx Flutter', - debugShowCheckedModeBanner: false, - theme: buildAppTheme(_settingsController.settings), - builder: (context, child) => AppLockGate( - enabled: !_settingsController.isLoading && - _settingsController.settings.appLockEnabled, - child: child ?? const SizedBox.shrink(), - ), - home: _buildHome(), - ); - }, + return MonetizationScope( + controller: _monetizationController, + rewardedAdService: _rewardedAdService, + purchaseService: _purchaseService, + child: AppSettingsScope( + controller: _settingsController, + child: AnimatedBuilder( + animation: _settingsController, + builder: (context, _) { + return MaterialApp( + title: 'AndroidIRCx Flutter', + debugShowCheckedModeBanner: false, + theme: buildAppTheme(_settingsController.settings), + builder: (context, child) => AppLockGate( + enabled: + !_settingsController.isLoading && + _settingsController.settings.appLockEnabled, + child: MonetizationBanner( + controller: _monetizationController, + onboardingCompleted: + _settingsController.settings.onboardingCompleted, + child: child ?? const SizedBox.shrink(), + ), + ), + home: _buildHome(), + ); + }, + ), ), ); } Widget _buildHome() { if (_settingsController.isLoading) { - return const Scaffold( - body: Center(child: CircularProgressIndicator()), - ); + return const Scaffold(body: Center(child: CircularProgressIndicator())); } if (!_settingsController.settings.onboardingCompleted) { final repository = diff --git a/lib/core/app/app_version.dart b/lib/core/app/app_version.dart index 74f25fb..508b4ea 100644 --- a/lib/core/app/app_version.dart +++ b/lib/core/app/app_version.dart @@ -1,5 +1,5 @@ -const appVersionName = '1.0.6'; -const appVersionCode = 9; +const appVersionName = '1.0.9'; +const appVersionCode = 13; const appVersion = '$appVersionName+$appVersionCode'; const ctcpVersionReply = 'AndroidIRCX Flutter v$appVersion'; diff --git a/lib/features/chat/application/chat_session_controller.dart b/lib/features/chat/application/chat_session_controller.dart index ee9841e..6e78292 100644 --- a/lib/features/chat/application/chat_session_controller.dart +++ b/lib/features/chat/application/chat_session_controller.dart @@ -70,6 +70,23 @@ enum ChannelUserAction { enum ChannelModerationAction { kick, ban, kickBan, quiet } +typedef ChannelUserDetails = ({ + String nick, + String details, + String? prefix, + int statusRank, +}); + +const channelUserStatusPrefixes = ['~', '&', '@', '%', '+']; + +int channelUserStatusRank(String? prefix) { + if (prefix == null || prefix.isEmpty) { + return channelUserStatusPrefixes.length; + } + final rank = channelUserStatusPrefixes.indexOf(prefix); + return rank == -1 ? channelUserStatusPrefixes.length : rank; +} + class IrcUserInfo { const IrcUserInfo({ required this.nick, @@ -519,11 +536,17 @@ class ChatSessionController extends ChangeNotifier { return List.unmodifiable(sorted); } - List<({String nick, String details})> get activeChannelUserDetails { - return List<({String nick, String details})>.unmodifiable( - activeChannelUsers.map( - (nick) => (nick: nick, details: userDetailsForNick(nick)), - ), + List get activeChannelUserDetails { + return List.unmodifiable( + activeChannelUsers.map((nick) { + final prefix = _channelUserPrefixFor(activeTabId, nick); + return ( + nick: nick, + details: userDetailsForNick(nick), + prefix: prefix, + statusRank: channelUserStatusRank(prefix), + ); + }), ); } diff --git a/lib/features/chat/presentation/channel_list_screen.dart b/lib/features/chat/presentation/channel_list_screen.dart index 4b1199b..d611675 100644 --- a/lib/features/chat/presentation/channel_list_screen.dart +++ b/lib/features/chat/presentation/channel_list_screen.dart @@ -1,6 +1,8 @@ import 'package:androidircx/core/models/channel_list_entry.dart'; import 'package:androidircx/features/chat/application/chat_session_controller.dart'; +import 'package:androidircx/features/chat/presentation/irc_formatted_text.dart'; import 'package:androidircx/features/chat/presentation/join_channel_dialog.dart'; +import 'package:androidircx/irc/parser/irc_formatter.dart'; import 'package:flutter/material.dart'; /// Browses the server channel list (LIST) with search and one-tap join. @@ -38,12 +40,16 @@ class _ChannelListScreenState extends State { final visible = query.isEmpty ? entries : entries - .where((entry) => - entry.name.toLowerCase().contains(query) || - entry.topic.toLowerCase().contains(query)) - .toList(growable: false); - return [...visible] - ..sort((a, b) => b.userCount.compareTo(a.userCount)); + .where( + (entry) => + entry.name.toLowerCase().contains(query) || + formatIrcPlainText( + entry.topic, + collapseWhitespace: true, + ).toLowerCase().contains(query), + ) + .toList(growable: false); + return [...visible]..sort((a, b) => b.userCount.compareTo(a.userCount)); } Future _join(ChannelListEntry entry) async { @@ -103,13 +109,22 @@ class _ChannelListScreenState extends State { separatorBuilder: (_, _) => const Divider(height: 1), itemBuilder: (context, index) { final entry = entries[index]; + final topicStyle = Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: Theme.of( + context, + ).colorScheme.onSurfaceVariant, + ); return ListTile( leading: const Icon(Icons.tag), title: Text(entry.name), subtitle: entry.topic.isEmpty ? null - : Text( + : IrcFormattedText( entry.topic, + baseStyle: topicStyle, maxLines: 2, overflow: TextOverflow.ellipsis, ), diff --git a/lib/features/chat/presentation/chat_screen.dart b/lib/features/chat/presentation/chat_screen.dart index 337be13..3970d01 100644 --- a/lib/features/chat/presentation/chat_screen.dart +++ b/lib/features/chat/presentation/chat_screen.dart @@ -19,15 +19,14 @@ import 'package:androidircx/features/chat/presentation/channel_list_screen.dart' import 'package:androidircx/features/chat/presentation/connection_details_screen.dart'; import 'package:androidircx/features/chat/presentation/media_player_screen.dart'; import 'package:androidircx/features/chat/presentation/ignore_list_screen.dart'; +import 'package:androidircx/features/chat/presentation/irc_formatted_text.dart'; import 'package:androidircx/features/chat/presentation/join_channel_dialog.dart'; import 'package:androidircx/features/chat/presentation/user_lists_screen.dart'; import 'package:androidircx/irc/parser/irc_formatter.dart'; -import 'package:androidircx/irc/parser/interactive_message_parser.dart'; import 'package:androidircx/irc/parser/message_content_parser.dart'; import 'package:androidircx/features/settings/presentation/settings_screen.dart'; import 'package:androidircx/media/services/link_preview_service.dart'; import 'package:androidircx/media/services/media_download_service.dart'; -import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:flutter/services.dart'; @@ -81,10 +80,12 @@ class _ChatScreenState extends State { final TextEditingController _composerController = TextEditingController(); final TextEditingController _messageSearchController = TextEditingController(); + final TextEditingController _nickSearchController = TextEditingController(); List _composerSuggestions = const []; List _autocompleteSuggestions = const []; bool _messageSearchVisible = false; _HistoryKindFilter _messageSearchFilter = _HistoryKindFilter.all; + String _nickSearchQuery = ''; IrcMessage? _pendingReplyMessage; ChatSessionController get _controller => widget.controller; @@ -111,6 +112,7 @@ class _ChatScreenState extends State { void dispose() { _composerController.dispose(); _messageSearchController.dispose(); + _nickSearchController.dispose(); super.dispose(); } @@ -307,50 +309,7 @@ class _ChatScreenState extends State { ), ), endDrawer: _controller.activeTab.type == ChatTabType.channel - ? Drawer( - child: SafeArea( - child: Column( - children: [ - ListTile( - title: Text(_controller.activeTab.name), - subtitle: Text( - '${_controller.activeChannelUsers.length} users', - ), - ), - const Divider(height: 1), - Expanded( - child: _controller.activeChannelUsers.isEmpty - ? const Center(child: Text('No nick list yet.')) - : ListView.builder( - itemCount: _controller - .activeChannelUserDetails - .length, - itemBuilder: (context, index) { - final entry = _controller - .activeChannelUserDetails[index]; - final nick = entry.nick; - return ListTile( - leading: const Icon( - Icons.person_outline, - ), - title: Text(nick), - subtitle: entry.details.isEmpty - ? null - : Text(entry.details), - onTap: () { - Navigator.of(context).pop(); - unawaited( - _showChannelUserActions(nick), - ); - }, - ); - }, - ), - ), - ], - ), - ), - ) + ? _buildNickListDrawer(context) : null, body: SafeArea( child: LayoutBuilder( @@ -992,6 +951,99 @@ class _ChatScreenState extends State { } } + Widget _buildNickListDrawer(BuildContext context) { + final allEntries = _controller.activeChannelUserDetails; + final normalizedQuery = _nickSearchQuery.trim().toLowerCase(); + final filteredEntries = normalizedQuery.isEmpty + ? allEntries + : allEntries + .where( + (entry) => + entry.nick.toLowerCase().contains(normalizedQuery) || + entry.details.toLowerCase().contains(normalizedQuery), + ) + .toList(growable: false); + final groups = _groupChannelUsers(filteredEntries); + final colorScheme = Theme.of(context).colorScheme; + + return Drawer( + child: SafeArea( + child: Column( + children: [ + ListTile( + title: Text(_controller.activeTab.name), + subtitle: Text( + normalizedQuery.isEmpty + ? '${allEntries.length} users' + : '${filteredEntries.length} of ${allEntries.length} users', + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), + child: TextField( + key: const ValueKey('channel-user-search'), + controller: _nickSearchController, + decoration: InputDecoration( + hintText: 'Search users', + isDense: true, + prefixIcon: const Icon(Icons.search), + suffixIcon: _nickSearchQuery.isEmpty + ? null + : IconButton( + tooltip: 'Clear search', + icon: const Icon(Icons.clear), + onPressed: () { + setState(() { + _nickSearchController.clear(); + _nickSearchQuery = ''; + }); + }, + ), + border: const OutlineInputBorder(), + ), + textInputAction: TextInputAction.search, + onChanged: (value) { + setState(() { + _nickSearchQuery = value; + }); + }, + ), + ), + const Divider(height: 1), + Expanded( + child: allEntries.isEmpty + ? const Center(child: Text('No nick list yet.')) + : filteredEntries.isEmpty + ? const Center(child: Text('No matching users.')) + : ListView( + children: [ + for (final group in groups) ...[ + _NickStatusHeader( + group: group, + color: _nickStatusColor(colorScheme, group.prefix), + ), + for (final entry in group.entries) + _NickStatusTile( + entry: entry, + color: _nickStatusColor( + colorScheme, + entry.prefix, + ), + onTap: () { + Navigator.of(context).pop(); + unawaited(_showChannelUserActions(entry.nick)); + }, + ), + ], + ], + ), + ), + ], + ), + ), + ); + } + Widget _buildNetworkSwitchTile(NetworkConfig network) { final isCurrent = network.id == _controller.network.id; final snapshot = widget.sessionRegistry?.connectionFor(network.id); @@ -2763,7 +2815,7 @@ class _ChannelTopicBar extends StatelessWidget { borderRadius: BorderRadius.circular(14), border: Border.all(color: ircTheme.messageBorder), ), - child: _IrcFormattedText( + child: IrcFormattedText( topic, maxLines: 2, overflow: TextOverflow.ellipsis, @@ -3090,7 +3142,7 @@ class _MessageList extends StatelessWidget { ), replyId: message.tags['draft/reply']!.trim(), ), - _IrcFormattedText( + IrcFormattedText( message.content, baseStyle: messageStyle, leading: leadingSpans, @@ -3287,188 +3339,130 @@ class _MessageList extends StatelessWidget { } } -class _IrcFormattedText extends StatelessWidget { - const _IrcFormattedText( - this.text, { - this.baseStyle, - this.maxLines, - this.overflow, - this.leading, - this.knownNicks = const {}, - this.channelPrefixes = '#&', - this.nickPrefixes = '~&@%+', - this.contextNick, - this.onNickTap, - this.onNickLongPress, - this.onChannelTap, +class _NickStatusGroup { + _NickStatusGroup({ + required this.prefix, + required this.title, + required this.entries, }); - final String text; - final TextStyle? baseStyle; - final int? maxLines; - final TextOverflow? overflow; + final String? prefix; + final String title; + final List entries; +} - /// Inline spans (e.g. sender + timestamp) rendered before the content so the - /// message flows on one line and only wraps when it is long. - final List? leading; - final Set knownNicks; - final String channelPrefixes; - final String nickPrefixes; - final String? contextNick; - final ValueChanged? onNickTap; - final ValueChanged? onNickLongPress; - final ValueChanged? onChannelTap; +List<_NickStatusGroup> _groupChannelUsers(List entries) { + final groups = <_NickStatusGroup>[ + _NickStatusGroup(prefix: '~', title: 'Owners', entries: []), + _NickStatusGroup(prefix: '&', title: 'Admins', entries: []), + _NickStatusGroup(prefix: '@', title: 'Operators', entries: []), + _NickStatusGroup(prefix: '%', title: 'Half operators', entries: []), + _NickStatusGroup(prefix: '+', title: 'Voiced', entries: []), + _NickStatusGroup(prefix: null, title: 'Regular', entries: []), + ]; - @override - Widget build(BuildContext context) { - final segments = parseInteractiveMessageTokens( - text, - knownNicks: knownNicks, - channelPrefixes: channelPrefixes, - nickPrefixes: nickPrefixes, - contextNick: contextNick, - ); - final contentSpans = segments.isEmpty - ? [TextSpan(text: text, style: baseStyle)] - : segments - .map((segment) => _spanForToken(context, segment)) - .toList(growable: false); + for (final entry in entries) { + final rank = entry.statusRank; + if (rank >= 0 && rank < groups.length - 1) { + groups[rank].entries.add(entry); + } else { + groups.last.entries.add(entry); + } + } - return Text.rich( - TextSpan(children: [...?leading, ...contentSpans]), - style: baseStyle, - maxLines: maxLines, - overflow: overflow, + for (final group in groups) { + group.entries.sort( + (a, b) => a.nick.toLowerCase().compareTo(b.nick.toLowerCase()), ); } + return groups.where((group) => group.entries.isNotEmpty).toList(); +} - InlineSpan _spanForToken( - BuildContext context, - InteractiveMessageToken token, - ) { - final style = _resolveTextStyle(baseStyle, token); - switch (token.type) { - case InteractiveMessageTokenType.url: - return TextSpan( - text: token.text, - style: style, - recognizer: TapGestureRecognizer() - ..onTap = () => _openExternalUrl(token.url!), - ); - case InteractiveMessageTokenType.channel: - final target = token.value; - if (target == null || onChannelTap == null) { - return TextSpan(text: token.text, style: style); - } - return WidgetSpan( - alignment: PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: () => onChannelTap!(target), - child: RichText( - text: TextSpan(text: token.text, style: style), - ), - ), - ); - case InteractiveMessageTokenType.nick: - case InteractiveMessageTokenType.hostmask: - case InteractiveMessageTokenType.userHost: - final target = token.value; - if (target == null || (onNickTap == null && onNickLongPress == null)) { - return TextSpan(text: token.text, style: style); - } - return WidgetSpan( - alignment: PlaceholderAlignment.baseline, - baseline: TextBaseline.alphabetic, - child: GestureDetector( - behavior: HitTestBehavior.translucent, - onTap: onNickTap == null ? null : () => onNickTap!(target), - onLongPress: onNickLongPress == null - ? null - : () => onNickLongPress!(target), - child: RichText( - text: TextSpan(text: token.text, style: style), - ), - ), - ); - case InteractiveMessageTokenType.text: - return TextSpan(text: token.text, style: style); - } - } +Color _nickStatusColor(ColorScheme colorScheme, String? prefix) { + return switch (prefix) { + '~' => const Color(0xFF9C27B0), + '&' => const Color(0xFFF44336), + '@' => const Color(0xFFFF9800), + '%' => const Color(0xFF2196F3), + '+' => const Color(0xFF4CAF50), + _ => colorScheme.onSurfaceVariant, + }; +} - TextStyle _resolveTextStyle( - TextStyle? base, - InteractiveMessageToken segment, - ) { - final style = segment.style; - final isLink = - segment.type == InteractiveMessageTokenType.url || - segment.type == InteractiveMessageTokenType.channel || - segment.type == InteractiveMessageTokenType.nick || - segment.type == InteractiveMessageTokenType.hostmask || - segment.type == InteractiveMessageTokenType.userHost; - var foregroundHex = - style.colorHex ?? - (style.color == null ? null : getIrcColorHex(style.color!)); - var backgroundHex = - style.backgroundHex ?? - (style.background == null ? null : getIrcColorHex(style.background!)); - - if (style.reverse && foregroundHex != null && backgroundHex != null) { - final swappedForeground = backgroundHex; - backgroundHex = foregroundHex; - foregroundHex = swappedForeground; - } else if (style.reverse && foregroundHex != null) { - backgroundHex = foregroundHex; - foregroundHex = null; - } else if (style.reverse && backgroundHex != null) { - foregroundHex = backgroundHex; - backgroundHex = null; - } +class _NickStatusHeader extends StatelessWidget { + const _NickStatusHeader({required this.group, required this.color}); - var textStyle = base ?? const TextStyle(); - if (foregroundHex != null) { - textStyle = textStyle.copyWith(color: _parseHexColor(foregroundHex)); - } - if (backgroundHex != null) { - textStyle = textStyle.copyWith( - backgroundColor: _parseHexColor(backgroundHex), - ); - } - if (style.bold) { - textStyle = textStyle.copyWith(fontWeight: FontWeight.bold); - } - if (style.italic) { - textStyle = textStyle.copyWith(fontStyle: FontStyle.italic); - } - if (style.monospace) { - textStyle = textStyle.copyWith(fontFamily: 'monospace'); - } + final _NickStatusGroup group; + final Color color; - final decorations = {}; - if (style.underline || isLink) { - decorations.add(TextDecoration.underline); - } - if (style.strikethrough) { - decorations.add(TextDecoration.lineThrough); - } - if (decorations.isNotEmpty) { - textStyle = textStyle.copyWith( - decoration: TextDecoration.combine(decorations.toList(growable: false)), - ); - } + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + return Container( + width: double.infinity, + color: color.withValues(alpha: 0.10), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + group.prefix == null + ? '${group.title} (${group.entries.length})' + : '${group.prefix} ${group.title} (${group.entries.length})', + style: textTheme.labelLarge?.copyWith( + color: color, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} - if (isLink && foregroundHex == null) { - textStyle = textStyle.copyWith(color: const Color(0xFF1565C0)); - } +class _NickStatusTile extends StatelessWidget { + const _NickStatusTile({ + required this.entry, + required this.color, + required this.onTap, + }); - return textStyle; - } + final ChannelUserDetails entry; + final Color color; + final VoidCallback onTap; - Color _parseHexColor(String value) { - final normalized = value.replaceFirst('#', ''); - return Color(int.parse('FF$normalized', radix: 16)); + @override + Widget build(BuildContext context) { + final prefix = entry.prefix; + return ListTile( + leading: SizedBox.square( + dimension: 40, + child: DecoratedBox( + decoration: ShapeDecoration( + color: color.withValues(alpha: 0.12), + shape: CircleBorder( + side: BorderSide(color: color.withValues(alpha: 0.35)), + ), + ), + child: Center( + child: prefix == null + ? Icon(Icons.person_outline, size: 20, color: color) + : Text( + prefix, + style: TextStyle( + color: color, + fontWeight: FontWeight.w800, + fontSize: 18, + ), + ), + ), + ), + ), + title: Text( + entry.nick, + style: TextStyle( + color: color, + fontWeight: prefix == null ? FontWeight.w500 : FontWeight.w700, + ), + ), + subtitle: entry.details.isEmpty ? null : Text(entry.details), + onTap: onTap, + ); } } diff --git a/lib/features/chat/presentation/irc_formatted_text.dart b/lib/features/chat/presentation/irc_formatted_text.dart new file mode 100644 index 0000000..267a9cf --- /dev/null +++ b/lib/features/chat/presentation/irc_formatted_text.dart @@ -0,0 +1,195 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'package:androidircx/irc/parser/interactive_message_parser.dart'; +import 'package:androidircx/irc/parser/irc_formatter.dart'; + +class IrcFormattedText extends StatelessWidget { + const IrcFormattedText( + this.text, { + super.key, + this.baseStyle, + this.maxLines, + this.overflow, + this.leading, + this.knownNicks = const {}, + this.channelPrefixes = '#&', + this.nickPrefixes = '~&@%+', + this.contextNick, + this.onNickTap, + this.onNickLongPress, + this.onChannelTap, + }); + + final String text; + final TextStyle? baseStyle; + final int? maxLines; + final TextOverflow? overflow; + + /// Inline spans (e.g. sender + timestamp) rendered before the content so the + /// message flows on one line and only wraps when it is long. + final List? leading; + final Set knownNicks; + final String channelPrefixes; + final String nickPrefixes; + final String? contextNick; + final ValueChanged? onNickTap; + final ValueChanged? onNickLongPress; + final ValueChanged? onChannelTap; + + @override + Widget build(BuildContext context) { + final segments = parseInteractiveMessageTokens( + text, + knownNicks: knownNicks, + channelPrefixes: channelPrefixes, + nickPrefixes: nickPrefixes, + contextNick: contextNick, + ); + final contentSpans = segments.isEmpty + ? [TextSpan(text: text, style: baseStyle)] + : segments.map(_spanForToken).toList(growable: false); + + return Text.rich( + TextSpan(children: [...?leading, ...contentSpans]), + style: baseStyle, + maxLines: maxLines, + overflow: overflow, + ); + } + + InlineSpan _spanForToken(InteractiveMessageToken token) { + final style = _resolveTextStyle(baseStyle, token); + switch (token.type) { + case InteractiveMessageTokenType.url: + return TextSpan( + text: token.text, + style: style, + recognizer: TapGestureRecognizer() + ..onTap = () => _openExternalUrl(token.url!), + ); + case InteractiveMessageTokenType.channel: + final target = token.value; + if (target == null || onChannelTap == null) { + return TextSpan(text: token.text, style: style); + } + return WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => onChannelTap!(target), + child: RichText( + text: TextSpan(text: token.text, style: style), + ), + ), + ); + case InteractiveMessageTokenType.nick: + case InteractiveMessageTokenType.hostmask: + case InteractiveMessageTokenType.userHost: + final target = token.value; + if (target == null || (onNickTap == null && onNickLongPress == null)) { + return TextSpan(text: token.text, style: style); + } + return WidgetSpan( + alignment: PlaceholderAlignment.baseline, + baseline: TextBaseline.alphabetic, + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: onNickTap == null ? null : () => onNickTap!(target), + onLongPress: onNickLongPress == null + ? null + : () => onNickLongPress!(target), + child: RichText( + text: TextSpan(text: token.text, style: style), + ), + ), + ); + case InteractiveMessageTokenType.text: + return TextSpan(text: token.text, style: style); + } + } + + TextStyle _resolveTextStyle( + TextStyle? base, + InteractiveMessageToken segment, + ) { + final style = segment.style; + final isLink = + segment.type == InteractiveMessageTokenType.url || + segment.type == InteractiveMessageTokenType.channel || + segment.type == InteractiveMessageTokenType.nick || + segment.type == InteractiveMessageTokenType.hostmask || + segment.type == InteractiveMessageTokenType.userHost; + var foregroundHex = + style.colorHex ?? + (style.color == null ? null : getIrcColorHex(style.color!)); + var backgroundHex = + style.backgroundHex ?? + (style.background == null ? null : getIrcColorHex(style.background!)); + + if (style.reverse && foregroundHex != null && backgroundHex != null) { + final swappedForeground = backgroundHex; + backgroundHex = foregroundHex; + foregroundHex = swappedForeground; + } else if (style.reverse && foregroundHex != null) { + backgroundHex = foregroundHex; + foregroundHex = null; + } else if (style.reverse && backgroundHex != null) { + foregroundHex = backgroundHex; + backgroundHex = null; + } + + var textStyle = base ?? const TextStyle(); + if (foregroundHex != null) { + textStyle = textStyle.copyWith(color: _parseHexColor(foregroundHex)); + } + if (backgroundHex != null) { + textStyle = textStyle.copyWith( + backgroundColor: _parseHexColor(backgroundHex), + ); + } + if (style.bold) { + textStyle = textStyle.copyWith(fontWeight: FontWeight.bold); + } + if (style.italic) { + textStyle = textStyle.copyWith(fontStyle: FontStyle.italic); + } + if (style.monospace) { + textStyle = textStyle.copyWith(fontFamily: 'monospace'); + } + + final decorations = {}; + if (style.underline || isLink) { + decorations.add(TextDecoration.underline); + } + if (style.strikethrough) { + decorations.add(TextDecoration.lineThrough); + } + if (decorations.isNotEmpty) { + textStyle = textStyle.copyWith( + decoration: TextDecoration.combine(decorations.toList(growable: false)), + ); + } + + if (isLink && foregroundHex == null) { + textStyle = textStyle.copyWith(color: const Color(0xFF1565C0)); + } + + return textStyle; + } + + Color _parseHexColor(String value) { + final normalized = value.replaceFirst('#', ''); + return Color(int.parse('FF$normalized', radix: 16)); + } +} + +Future _openExternalUrl(String value) async { + final uri = Uri.tryParse(value.startsWith('http') ? value : 'https://$value'); + if (uri == null) { + return; + } + await launchUrl(uri, mode: LaunchMode.externalApplication); +} diff --git a/lib/features/monetization/presentation/monetization_banner.dart b/lib/features/monetization/presentation/monetization_banner.dart new file mode 100644 index 0000000..3b5402b --- /dev/null +++ b/lib/features/monetization/presentation/monetization_banner.dart @@ -0,0 +1,236 @@ +import 'dart:async'; + +import 'package:androidircx/monetization/monetization_config.dart'; +import 'package:androidircx/monetization/monetization_controller.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:google_mobile_ads/google_mobile_ads.dart'; + +class MonetizationBanner extends StatefulWidget { + const MonetizationBanner({ + super.key, + required this.controller, + required this.onboardingCompleted, + required this.child, + }); + + final MonetizationController controller; + final bool onboardingCompleted; + final Widget child; + + @override + State createState() => _MonetizationBannerState(); +} + +class _MonetizationBannerState extends State { + static const _retryDelay = Duration(seconds: 30); + + BannerAd? _bannerAd; + bool _loaded = false; + bool _loading = false; + String? _lastFailure; + Timer? _retryTimer; + + @override + void initState() { + super.initState(); + widget.controller.addListener(_syncBannerState); + _syncBannerState(); + } + + @override + void didUpdateWidget(covariant MonetizationBanner oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + oldWidget.controller.removeListener(_syncBannerState); + widget.controller.addListener(_syncBannerState); + } + _syncBannerState(); + } + + @override + void dispose() { + widget.controller.removeListener(_syncBannerState); + _retryTimer?.cancel(); + _disposeBanner(); + super.dispose(); + } + + void _syncBannerState() { + final shouldShow = widget.controller.shouldShowBanner( + onboardingCompleted: widget.onboardingCompleted, + ); + if (!shouldShow) { + _retryTimer?.cancel(); + _retryTimer = null; + _loading = false; + _lastFailure = null; + _disposeBanner(); + if (mounted) { + setState(() {}); + } + return; + } + if (_bannerAd == null && !_loading) { + _loadBanner(); + } + } + + void _loadBanner() { + if (!MonetizationConfig.mobileAdsRuntimeSupported) { + return; + } + _retryTimer?.cancel(); + _retryTimer = null; + _loading = true; + _loaded = false; + _lastFailure = null; + if (mounted) { + setState(() {}); + } + final ad = BannerAd( + adUnitId: MonetizationConfig.bannerAdUnitId, + request: const AdRequest(nonPersonalizedAds: true), + size: AdSize.banner, + listener: BannerAdListener( + onAdLoaded: (ad) { + if (!mounted || !identical(_bannerAd, ad)) { + ad.dispose(); + return; + } + setState(() { + _loaded = true; + _loading = false; + _lastFailure = null; + }); + }, + onAdFailedToLoad: (ad, error) { + ad.dispose(); + if (!mounted) { + return; + } + setState(() { + if (identical(_bannerAd, ad)) { + _bannerAd = null; + _loaded = false; + _loading = false; + _lastFailure = '${error.code}: ${error.message}'; + } + }); + _scheduleRetry(); + }, + ), + ); + _bannerAd = ad; + try { + ad.load(); + } catch (error) { + ad.dispose(); + _bannerAd = null; + _loaded = false; + _loading = false; + _lastFailure = error.toString(); + if (mounted) { + setState(() {}); + _scheduleRetry(); + } + } + } + + void _disposeBanner() { + _bannerAd?.dispose(); + _bannerAd = null; + _loaded = false; + } + + void _scheduleRetry() { + if (_retryTimer != null) { + return; + } + _retryTimer = Timer(_retryDelay, () { + _retryTimer = null; + if (!mounted) { + return; + } + if (widget.controller.shouldShowBanner( + onboardingCompleted: widget.onboardingCompleted, + )) { + _loadBanner(); + } + }); + } + + @override + Widget build(BuildContext context) { + final shouldShow = widget.controller.shouldShowBanner( + onboardingCompleted: widget.onboardingCompleted, + ); + final banner = _loaded && _bannerAd != null + ? SafeArea( + bottom: false, + child: Material( + color: Theme.of(context).colorScheme.surface, + elevation: 1, + child: SizedBox( + width: double.infinity, + height: AdSize.banner.height.toDouble(), + child: Center( + child: SizedBox( + width: AdSize.banner.width.toDouble(), + height: AdSize.banner.height.toDouble(), + child: AdWidget(ad: _bannerAd!), + ), + ), + ), + ), + ) + : shouldShow && !kReleaseMode + ? _BannerLoadStatus(loading: _loading, failure: _lastFailure) + : const SizedBox.shrink(); + + return Column( + children: [ + banner, + Expanded(child: widget.child), + ], + ); + } +} + +class _BannerLoadStatus extends StatelessWidget { + const _BannerLoadStatus({required this.loading, required this.failure}); + + final bool loading; + final String? failure; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final text = failure == null + ? loading + ? 'Banner ad loading' + : 'Banner ad pending' + : 'Banner ad failed: $failure'; + return SafeArea( + bottom: false, + child: Material( + color: colorScheme.surfaceContainerHighest, + elevation: 1, + child: SizedBox( + height: AdSize.banner.height.toDouble(), + width: double.infinity, + child: Center( + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/monetization/presentation/purchase_screen.dart b/lib/features/monetization/presentation/purchase_screen.dart new file mode 100644 index 0000000..5165972 --- /dev/null +++ b/lib/features/monetization/presentation/purchase_screen.dart @@ -0,0 +1,204 @@ +import 'dart:async'; + +import 'package:androidircx/monetization/monetization_config.dart'; +import 'package:androidircx/monetization/monetization_controller.dart'; +import 'package:androidircx/monetization/store_purchase_service.dart'; +import 'package:flutter/material.dart'; + +class PurchaseScreen extends StatefulWidget { + const PurchaseScreen({ + super.key, + required this.monetizationController, + required this.purchaseService, + }); + + final MonetizationController monetizationController; + final StorePurchaseService purchaseService; + + @override + State createState() => _PurchaseScreenState(); +} + +class _PurchaseScreenState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(widget.purchaseService.initialize()); + }); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: Listenable.merge([ + widget.monetizationController, + widget.purchaseService, + ]), + builder: (context, _) { + return Scaffold( + appBar: AppBar(title: const Text('AndroidIRCX Premium')), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + _tierText(widget.monetizationController.highestTier), + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + for (final product in MonetizationConfig.products) ...[ + _ProductCard( + product: product, + monetizationController: widget.monetizationController, + purchaseService: widget.purchaseService, + ), + const SizedBox(height: 12), + ], + FilledButton.icon( + onPressed: + widget.purchaseService.storeAvailable && + !widget.purchaseService.restoring + ? widget.purchaseService.restorePurchases + : null, + icon: widget.purchaseService.restoring + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.restore), + label: const Text('Restore purchases'), + ), + if ((widget.purchaseService.statusMessage ?? '').isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text(widget.purchaseService.statusMessage!), + ), + if (widget.purchaseService.notFoundProductIds.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + 'Missing Play products: ' + '${widget.purchaseService.notFoundProductIds.join(', ')}', + ), + ), + const SizedBox(height: 12), + const Text( + 'Purchases are processed by Google Play. Product IDs must be ' + 'created as one-time in-app products in Play Console before ' + 'prices appear here.', + ), + ], + ), + ), + ); + }, + ); + } + + String _tierText(PremiumTier tier) { + return switch (tier) { + PremiumTier.free => 'Current plan: Free', + PremiumTier.removeAds => 'Current plan: Remove Ads', + PremiumTier.proUnlimited => 'Current plan: Pro Unlimited', + PremiumTier.supporterPro => 'Current plan: Supporter Pro', + }; + } +} + +class _ProductCard extends StatelessWidget { + const _ProductCard({ + required this.product, + required this.monetizationController, + required this.purchaseService, + }); + + final MonetizationProduct product; + final MonetizationController monetizationController; + final StorePurchaseService purchaseService; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final details = purchaseService.productDetailsFor(product.id); + final purchased = monetizationController.hasPurchased(product.id); + final pending = purchaseService.pendingProductId == product.id; + final available = purchaseService.storeAvailable && details != null; + + return Card( + clipBehavior: Clip.antiAlias, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + product.title, + style: theme.textTheme.titleMedium, + ), + ), + if (product.recommended) + Chip( + visualDensity: VisualDensity.compact, + label: Text( + 'Recommended', + style: theme.textTheme.labelSmall, + ), + ), + ], + ), + const SizedBox(height: 6), + Text(product.description), + const SizedBox(height: 10), + for (final feature in product.features) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + children: [ + Icon( + Icons.check, + size: 16, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 8), + Expanded(child: Text(feature)), + ], + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Text( + details?.price ?? + (MonetizationConfig.storeRuntimeSupported + ? 'Create in Play Console' + : 'Mobile store only'), + style: theme.textTheme.titleSmall, + ), + ), + FilledButton( + onPressed: purchased || pending || !available + ? null + : () => purchaseService.buyProduct(product.id), + child: pending + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(purchased ? 'Purchased' : 'Purchase'), + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/onboarding/presentation/data_privacy_screen.dart b/lib/features/onboarding/presentation/data_privacy_screen.dart index 1800237..fe26b37 100644 --- a/lib/features/onboarding/presentation/data_privacy_screen.dart +++ b/lib/features/onboarding/presentation/data_privacy_screen.dart @@ -17,43 +17,50 @@ class DataPrivacyScreen extends StatelessWidget { child: ListView( padding: const EdgeInsets.all(20), children: [ - Text('Your data stays on your device', - style: theme.textTheme.titleLarge), + Text( + 'Your data stays on your device', + style: theme.textTheme.titleLarge, + ), const SizedBox(height: 12), const _PrivacyPoint( icon: Icons.phone_android, title: 'Local-first', - body: 'Networks, settings, and chat history are stored on this ' + body: + 'Networks, settings, and chat history are stored on this ' 'device. AndroidIRCX has no account and no cloud sync.', ), const _PrivacyPoint( icon: Icons.lock_outline, title: 'Encrypted history', - body: 'Message history is encrypted with a key protected by your ' + body: + 'Message history is encrypted with a key protected by your ' 'fingerprint/PIN, so a stolen database file cannot be read.', ), const _PrivacyPoint( icon: Icons.vpn_key_outlined, title: 'Secrets in secure storage', - body: 'Server, SASL, and channel passwords and client ' + body: + 'Server, SASL, and channel passwords and client ' 'certificates are kept in the platform secure storage ' '(Android Keystore), never in plain files or exports.', ), const _PrivacyPoint( icon: Icons.dns_outlined, title: 'Direct IRC connections', - body: 'The app connects straight to the IRC servers you choose. ' + body: + 'The app connects straight to the IRC servers you choose. ' 'Message content is sent to those servers per the IRC ' 'protocol; use TLS and SASL for privacy in transit.', ), const _PrivacyPoint( icon: Icons.insights_outlined, - title: 'Optional analytics & crash reports', + title: 'Ads, analytics & crash reports', body: - 'No ads at the moment 🙂. Anonymous usage analytics and crash ' - 'reports (Firebase Analytics/Crashlytics) are OFF by default ' - 'and only collected if you opt in; you can change this any ' - 'time in Settings.', + 'AndroidIRCX uses Google AdMob banner ads and opt-in ' + 'rewarded ads that can temporarily hide banners. Anonymous ' + 'usage analytics and crash reports (Firebase Analytics/' + 'Crashlytics) are OFF by default and only collected if you ' + 'opt in; you can change this any time in Settings.', ), const SizedBox(height: 16), FilledButton.icon( diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index d616847..2e015de 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -10,10 +10,14 @@ import 'package:androidircx/core/storage/shared_prefs_settings_repository.dart'; import 'package:androidircx/features/connections/application/network_list_controller.dart'; import 'package:androidircx/features/connections/presentation/profiles_screen.dart'; import 'package:androidircx/features/connections/presentation/server_directory_picker.dart'; +import 'package:androidircx/features/monetization/presentation/purchase_screen.dart'; import 'package:androidircx/features/onboarding/presentation/data_privacy_screen.dart'; import 'package:androidircx/features/settings/presentation/backup_screen.dart'; import 'package:androidircx/features/settings/presentation/crash_reports_screen.dart'; import 'package:androidircx/features/settings/presentation/theme_editor_screen.dart'; +import 'package:androidircx/monetization/monetization_config.dart'; +import 'package:androidircx/monetization/monetization_controller.dart'; +import 'package:androidircx/monetization/monetization_scope.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:local_auth/local_auth.dart'; @@ -102,6 +106,7 @@ class _SettingsScreenState extends State { @override Widget build(BuildContext context) { + final monetizationScope = MonetizationScope.maybeOf(context); return Scaffold( appBar: AppBar(title: const Text('Settings')), body: SafeArea( @@ -438,6 +443,58 @@ class _SettingsScreenState extends State { ], ), const SizedBox(height: 12), + if (monetizationScope != null) ...[ + _SettingsSection( + title: 'Premium & ads', + children: [ + _MonetizationStatusTile( + controller: monetizationScope.controller, + ), + const Divider(height: 1), + AnimatedBuilder( + animation: Listenable.merge([ + monetizationScope.controller, + monetizationScope.rewardedAdService, + ]), + builder: (context, _) { + final rewarded = + monetizationScope.rewardedAdService; + final canRequestAd = + MonetizationConfig.mobileAdsRuntimeSupported && + !rewarded.isLoading && + !rewarded.isShowing && + !rewarded.isInCooldown; + return ListTile( + leading: const Icon(Icons.play_circle_outline), + title: Text(_watchAdTitle(monetizationScope)), + subtitle: Text( + _watchAdSubtitle(monetizationScope), + ), + trailing: FilledButton( + onPressed: canRequestAd + ? () => _handleWatchAd(monetizationScope) + : null, + child: Text( + rewarded.isReady ? 'Watch' : 'Load', + ), + ), + ); + }, + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.workspace_premium_outlined), + title: const Text('Remove ads permanently'), + subtitle: const Text( + 'Create matching Play products, then sell ' + 'one-time no-ads upgrades here.', + ), + onTap: () => _openPurchaseScreen(monetizationScope), + ), + ], + ), + const SizedBox(height: 12), + ], _SettingsSection( title: 'Security', children: [ @@ -454,7 +511,9 @@ class _SettingsScreenState extends State { const Divider(height: 1), SwitchListTile( key: const Key('settings-screenshot-protection'), - secondary: const Icon(Icons.screenshot_monitor_outlined), + secondary: const Icon( + Icons.screenshot_monitor_outlined, + ), title: const Text('Block screenshots'), subtitle: const Text( 'Prevent screenshots and screen recording (Android).', @@ -502,7 +561,9 @@ class _SettingsScreenState extends State { value: _settings.notifyPrivateMessages, onChanged: _settings.notificationsEnabled ? (value) => _saveSettings( - _settings.copyWith(notifyPrivateMessages: value), + _settings.copyWith( + notifyPrivateMessages: value, + ), ) : null, ), @@ -1048,6 +1109,75 @@ class _SettingsScreenState extends State { ).showSnackBar(const SnackBar(content: Text('Theme JSON copied.'))); } + Future _handleWatchAd(MonetizationScope scope) async { + final messenger = ScaffoldMessenger.of(context); + final service = scope.rewardedAdService; + if (service.isReady) { + final result = await service.showRewardedAd(); + if (!mounted) { + return; + } + messenger.showSnackBar(SnackBar(content: Text(result.message))); + return; + } + + final result = await service.manualLoadAd(); + if (!mounted) { + return; + } + messenger.showSnackBar(SnackBar(content: Text(result.message))); + } + + Future _openPurchaseScreen(MonetizationScope scope) { + return Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PurchaseScreen( + monetizationController: scope.controller, + purchaseService: scope.purchaseService, + ), + ), + ); + } + + String _watchAdTitle(MonetizationScope scope) { + final service = scope.rewardedAdService; + if (!MonetizationConfig.mobileAdsRuntimeSupported) { + return 'Rewarded ads unavailable here'; + } + if (service.isShowing) { + return 'Showing rewarded ad'; + } + if (service.isReady) { + return 'Watch ad to hide banner'; + } + if (service.isInCooldown) { + return 'Ad cooldown (${service.cooldownSeconds}s)'; + } + if (service.isLoading) { + return 'Loading rewarded ad'; + } + return 'Request rewarded ad'; + } + + String _watchAdSubtitle(MonetizationScope scope) { + final controller = scope.controller; + final service = scope.rewardedAdService; + if (!MonetizationConfig.mobileAdsRuntimeSupported) { + return 'Use an Android or iOS build to request AdMob rewarded ads.'; + } + if (controller.hasNoAds) { + return 'You already have permanent no-ads. Watching ads is optional support.'; + } + if (controller.hasTemporaryAdFreeTime) { + return 'Banner hidden for ${controller.adFreeTimeFormatted}.'; + } + if ((service.lastError ?? '').isNotEmpty && !service.isLoading) { + return service.lastError!; + } + return 'Completing an ad grants ' + '${MonetizationConfig.rewardAdFreeMinutes} minutes without banners.'; + } + Future _showInfoDialog({required String title, required String body}) { return showDialog( context: context, @@ -1104,9 +1234,48 @@ Network passwords, SASL passwords, proxy passwords, and auto-join channel keys a IRC messages are sent to the networks you connect to. DCC transfers connect directly to the peer or through reverse/passive negotiation when available. -No ads at the moment :). Anonymous usage analytics and crash reports (Firebase Analytics/Crashlytics) are off by default and only collected if you opt in under Permissions; you can turn them off any time. +AndroidIRCX uses Google AdMob for top banner ads and opt-in rewarded ads. Tap "Watch ad" to earn temporary banner-free time. Anonymous usage analytics and crash reports (Firebase Analytics/Crashlytics) are off by default and only collected if you opt in under Permissions; you can turn them off any time. '''; +class _MonetizationStatusTile extends StatelessWidget { + const _MonetizationStatusTile({required this.controller}); + + final MonetizationController controller; + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: controller, + builder: (context, _) { + return ListTile( + leading: const Icon(Icons.ads_click_outlined), + title: Text(_titleFor(controller.highestTier)), + subtitle: Text(_subtitleFor(controller)), + ); + }, + ); + } + + String _titleFor(PremiumTier tier) { + return switch (tier) { + PremiumTier.free => 'Free plan', + PremiumTier.removeAds => 'Remove Ads active', + PremiumTier.proUnlimited => 'Pro Unlimited active', + PremiumTier.supporterPro => 'Supporter Pro active', + }; + } + + String _subtitleFor(MonetizationController controller) { + if (controller.hasNoAds) { + return 'Banner ads are permanently disabled.'; + } + if (controller.hasTemporaryAdFreeTime) { + return 'Banner hidden for ${controller.adFreeTimeFormatted}.'; + } + return 'Top banner ads are shown. Rewarded ads can hide them temporarily.'; + } +} + class _SettingsSection extends StatelessWidget { const _SettingsSection({required this.title, required this.children}); diff --git a/lib/main.dart b/lib/main.dart index ea444e4..b12c4f2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,7 +1,11 @@ +import 'dart:async'; + import 'package:androidircx/app/app.dart'; import 'package:androidircx/core/diagnostics/crash_reporter.dart'; import 'package:androidircx/core/firebase/firebase_service.dart'; +import 'package:androidircx/monetization/monetization_config.dart'; import 'package:flutter/widgets.dart'; +import 'package:google_mobile_ads/google_mobile_ads.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -14,5 +18,8 @@ Future main() async { } catch (_) { // Continue without Firebase if initialization fails. } + if (MonetizationConfig.mobileAdsRuntimeSupported) { + unawaited(MobileAds.instance.initialize()); + } runApp(const AndroidIrcxApp()); } diff --git a/lib/monetization/monetization_config.dart b/lib/monetization/monetization_config.dart new file mode 100644 index 0000000..b43fe06 --- /dev/null +++ b/lib/monetization/monetization_config.dart @@ -0,0 +1,116 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +class MonetizationConfig { + const MonetizationConfig._(); + + static const admobAppId = 'ca-app-pub-5116758828202889~8896612072'; + + static const productionBannerAdUnitId = + 'ca-app-pub-5116758828202889/3084997712'; + static const productionRewardedAdUnitId = + 'ca-app-pub-5116758828202889/3979276988'; + + static const testBannerAdUnitId = 'ca-app-pub-3940256099942544/9214589741'; + static const testRewardedAdUnitId = 'ca-app-pub-3940256099942544/5224354917'; + + static const rewardAdFreeMinutes = 60; + + static const productRemoveAds = 'remove_ads'; + static const productProUnlimited = 'pro_unlimited'; + static const productSupporterPro = 'supporter_pro'; + + static const productIds = { + productRemoveAds, + productProUnlimited, + productSupporterPro, + }; + + static const products = [ + MonetizationProduct( + id: productRemoveAds, + title: 'Remove Ads', + description: 'Remove all banner advertisements from the app.', + features: [ + 'No banner ads', + 'One-time purchase', + 'Lifetime access', + ], + ), + MonetizationProduct( + id: productProUnlimited, + title: 'Pro: Unlimited', + description: 'No ads now, plus future unlimited scripting entitlement.', + recommended: true, + features: [ + 'No banner ads', + 'Future unlimited scripting', + 'One-time purchase', + 'Lifetime access', + ], + ), + MonetizationProduct( + id: productSupporterPro, + title: 'Supporter Pro', + description: 'All Pro features plus a supporter entitlement.', + features: [ + 'No banner ads', + 'Future unlimited scripting', + 'Supporter status', + 'Supports open-source development', + 'One-time purchase', + 'Lifetime access', + ], + ), + ]; + + static String get bannerAdUnitId => + kReleaseMode ? productionBannerAdUnitId : testBannerAdUnitId; + + static String get rewardedAdUnitId => + kReleaseMode ? productionRewardedAdUnitId : testRewardedAdUnitId; + + static bool get usesProductionAds => kReleaseMode; + + static bool get mobileAdsRuntimeSupported { + if (kIsWeb || _isWidgetTestBinding()) { + return false; + } + return defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS; + } + + static bool get storeRuntimeSupported { + if (kIsWeb || _isWidgetTestBinding()) { + return false; + } + return defaultTargetPlatform == TargetPlatform.android || + defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS; + } +} + +class MonetizationProduct { + const MonetizationProduct({ + required this.id, + required this.title, + required this.description, + required this.features, + this.recommended = false, + }); + + final String id; + final String title; + final String description; + final List features; + final bool recommended; +} + +bool _isWidgetTestBinding() { + var isTest = false; + assert(() { + isTest = WidgetsBinding.instance.runtimeType.toString().contains('Test'); + return true; + }()); + return isTest; +} diff --git a/lib/monetization/monetization_controller.dart b/lib/monetization/monetization_controller.dart new file mode 100644 index 0000000..3cf5b3d --- /dev/null +++ b/lib/monetization/monetization_controller.dart @@ -0,0 +1,272 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:androidircx/monetization/monetization_config.dart'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +enum PremiumTier { free, removeAds, proUnlimited, supporterPro } + +class MonetizationController extends ChangeNotifier { + static const _purchasesKey = 'androidircx.monetization.purchases'; + static const _purchaseTokensKey = 'androidircx.monetization.purchaseTokens'; + static const _adFreeTimeKey = 'androidircx.monetization.adFreeTime'; + + bool _initialized = false; + bool _removeAds = false; + bool _proUnlimited = false; + bool _supporterPro = false; + int _adFreeMs = 0; + int _lastUpdatedMs = DateTime.now().millisecondsSinceEpoch; + int _ticksSinceSave = 0; + Timer? _adFreeTimer; + + bool get initialized => _initialized; + bool get hasRemoveAds => _removeAds; + bool get hasProUnlimited => _proUnlimited; + bool get isSupporter => _supporterPro; + + bool get hasNoAds => _removeAds || _proUnlimited || _supporterPro; + bool get hasUnlimitedScripting => _proUnlimited || _supporterPro; + bool get hasTemporaryAdFreeTime => _adFreeMs > 0; + int get adFreeTimeMs => _adFreeMs; + + PremiumTier get highestTier { + if (_supporterPro) { + return PremiumTier.supporterPro; + } + if (_proUnlimited) { + return PremiumTier.proUnlimited; + } + if (_removeAds) { + return PremiumTier.removeAds; + } + return PremiumTier.free; + } + + Future initialize() async { + if (_initialized) { + return; + } + await _loadPurchases(); + await _loadAdFreeTime(); + _initialized = true; + if (_adFreeMs > 0) { + _startAdFreeTimer(); + } + notifyListeners(); + } + + bool shouldShowBanner({required bool onboardingCompleted}) { + return _initialized && + onboardingCompleted && + MonetizationConfig.mobileAdsRuntimeSupported && + !hasNoAds && + !hasTemporaryAdFreeTime; + } + + Future processPurchase(String productId, String purchaseToken) async { + if (!MonetizationConfig.productIds.contains(productId)) { + return false; + } + if (productId == MonetizationConfig.productRemoveAds) { + _removeAds = true; + } else if (productId == MonetizationConfig.productProUnlimited) { + _proUnlimited = true; + } else if (productId == MonetizationConfig.productSupporterPro) { + _supporterPro = true; + } + + await _savePurchases(); + if (purchaseToken.trim().isNotEmpty) { + await _storePurchaseToken(productId, purchaseToken.trim()); + } + notifyListeners(); + return true; + } + + bool hasPurchased(String productId) { + return switch (productId) { + MonetizationConfig.productRemoveAds => _removeAds, + MonetizationConfig.productProUnlimited => _proUnlimited, + MonetizationConfig.productSupporterPro => _supporterPro, + _ => false, + }; + } + + Future getPurchaseToken(String productId) async { + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_purchaseTokensKey); + if (raw == null || raw.isEmpty) { + return null; + } + final decoded = jsonDecode(raw) as Map; + return decoded[productId] as String?; + } catch (_) { + return null; + } + } + + Future grantTemporaryAdFreeTime(Duration duration) async { + if (duration <= Duration.zero) { + return; + } + _adFreeMs += duration.inMilliseconds; + _lastUpdatedMs = DateTime.now().millisecondsSinceEpoch; + _ticksSinceSave = 0; + await _saveAdFreeTime(); + _startAdFreeTimer(); + notifyListeners(); + } + + Future resetTemporaryAdFreeTime() async { + _adFreeMs = 0; + _lastUpdatedMs = DateTime.now().millisecondsSinceEpoch; + _ticksSinceSave = 0; + _adFreeTimer?.cancel(); + _adFreeTimer = null; + await _saveAdFreeTime(); + notifyListeners(); + } + + String get adFreeTimeFormatted => formatDurationMs(_adFreeMs); + + static String formatDurationMs(int ms) { + final safeMs = ms < 0 ? 0 : ms; + final hours = safeMs ~/ Duration.millisecondsPerHour; + final minutes = + (safeMs % Duration.millisecondsPerHour) ~/ + Duration.millisecondsPerMinute; + final seconds = + (safeMs % Duration.millisecondsPerMinute) ~/ + Duration.millisecondsPerSecond; + if (hours > 0) { + return '${hours}h ${minutes}m'; + } + if (minutes > 0) { + return '${minutes}m ${seconds}s'; + } + return '${seconds}s'; + } + + Future _loadPurchases() async { + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_purchasesKey); + if (raw == null || raw.isEmpty) { + return; + } + final decoded = jsonDecode(raw) as Map; + _removeAds = + decoded[MonetizationConfig.productRemoveAds] as bool? ?? false; + _proUnlimited = + decoded[MonetizationConfig.productProUnlimited] as bool? ?? false; + _supporterPro = + decoded[MonetizationConfig.productSupporterPro] as bool? ?? false; + } catch (_) { + _removeAds = false; + _proUnlimited = false; + _supporterPro = false; + } + } + + Future _savePurchases() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _purchasesKey, + jsonEncode({ + MonetizationConfig.productRemoveAds: _removeAds, + MonetizationConfig.productProUnlimited: _proUnlimited, + MonetizationConfig.productSupporterPro: _supporterPro, + }), + ); + } + + Future _storePurchaseToken(String productId, String token) async { + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_purchaseTokensKey); + final tokens = raw == null || raw.isEmpty + ? {} + : jsonDecode(raw) as Map; + tokens[productId] = token; + await prefs.setString(_purchaseTokensKey, jsonEncode(tokens)); + } catch (_) { + // The entitlement is already granted locally. Token storage is best effort + // until backend verification is introduced. + } + } + + Future _loadAdFreeTime() async { + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_adFreeTimeKey); + if (raw == null || raw.isEmpty) { + return; + } + final decoded = jsonDecode(raw) as Map; + _adFreeMs = (decoded['remainingMs'] as num?)?.toInt() ?? 0; + _lastUpdatedMs = + (decoded['lastUpdated'] as num?)?.toInt() ?? + DateTime.now().millisecondsSinceEpoch; + _adFreeMs = _adFreeMs < 0 ? 0 : _adFreeMs; + final elapsed = DateTime.now().millisecondsSinceEpoch - _lastUpdatedMs; + if (elapsed > 0) { + _adFreeMs = (_adFreeMs - elapsed).clamp(0, _adFreeMs).toInt(); + _lastUpdatedMs = DateTime.now().millisecondsSinceEpoch; + await prefs.setString( + _adFreeTimeKey, + jsonEncode({ + 'remainingMs': _adFreeMs, + 'lastUpdated': _lastUpdatedMs, + }), + ); + } + } catch (_) { + _adFreeMs = 0; + _lastUpdatedMs = DateTime.now().millisecondsSinceEpoch; + } + } + + Future _saveAdFreeTime() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString( + _adFreeTimeKey, + jsonEncode({ + 'remainingMs': _adFreeMs, + 'lastUpdated': _lastUpdatedMs, + }), + ); + } + + void _startAdFreeTimer() { + if (_adFreeTimer != null || _adFreeMs <= 0) { + return; + } + _lastUpdatedMs = DateTime.now().millisecondsSinceEpoch; + _adFreeTimer = Timer.periodic(const Duration(seconds: 1), (_) { + final now = DateTime.now().millisecondsSinceEpoch; + final elapsed = now - _lastUpdatedMs; + _lastUpdatedMs = now; + _adFreeMs = (_adFreeMs - elapsed).clamp(0, _adFreeMs).toInt(); + _ticksSinceSave += 1; + + if (_adFreeMs <= 0) { + _adFreeTimer?.cancel(); + _adFreeTimer = null; + unawaited(_saveAdFreeTime()); + } else if (_ticksSinceSave >= 10) { + _ticksSinceSave = 0; + unawaited(_saveAdFreeTime()); + } + notifyListeners(); + }); + } + + @override + void dispose() { + _adFreeTimer?.cancel(); + super.dispose(); + } +} diff --git a/lib/monetization/monetization_scope.dart b/lib/monetization/monetization_scope.dart new file mode 100644 index 0000000..54d7025 --- /dev/null +++ b/lib/monetization/monetization_scope.dart @@ -0,0 +1,36 @@ +import 'package:androidircx/monetization/monetization_controller.dart'; +import 'package:androidircx/monetization/rewarded_ad_service.dart'; +import 'package:androidircx/monetization/store_purchase_service.dart'; +import 'package:flutter/widgets.dart'; + +class MonetizationScope extends InheritedWidget { + const MonetizationScope({ + super.key, + required this.controller, + required this.rewardedAdService, + required this.purchaseService, + required super.child, + }); + + final MonetizationController controller; + final RewardedAdService rewardedAdService; + final StorePurchaseService purchaseService; + + static MonetizationScope of(BuildContext context) { + final scope = context + .dependOnInheritedWidgetOfExactType(); + assert(scope != null, 'No MonetizationScope found in context.'); + return scope!; + } + + static MonetizationScope? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + @override + bool updateShouldNotify(MonetizationScope oldWidget) { + return controller != oldWidget.controller || + rewardedAdService != oldWidget.rewardedAdService || + purchaseService != oldWidget.purchaseService; + } +} diff --git a/lib/monetization/rewarded_ad_service.dart b/lib/monetization/rewarded_ad_service.dart new file mode 100644 index 0000000..bf30ebb --- /dev/null +++ b/lib/monetization/rewarded_ad_service.dart @@ -0,0 +1,192 @@ +import 'dart:async'; + +import 'package:androidircx/monetization/monetization_config.dart'; +import 'package:androidircx/monetization/monetization_controller.dart'; +import 'package:flutter/foundation.dart'; +import 'package:google_mobile_ads/google_mobile_ads.dart'; + +class RewardedAdResult { + const RewardedAdResult({required this.success, required this.message}); + + final bool success; + final String message; +} + +class RewardedAdService extends ChangeNotifier { + RewardedAdService({required MonetizationController monetizationController}) + : _monetizationController = monetizationController; + + final MonetizationController _monetizationController; + RewardedAd? _rewardedAd; + bool _loading = false; + bool _showing = false; + int _retryCount = 0; + DateTime? _cooldownUntil; + Timer? _cooldownTimer; + String? _lastError; + + bool get isReady => _rewardedAd != null; + bool get isLoading => _loading; + bool get isShowing => _showing; + bool get isInCooldown => + _cooldownUntil != null && DateTime.now().isBefore(_cooldownUntil!); + int get cooldownSeconds { + final until = _cooldownUntil; + if (until == null) { + return 0; + } + final remaining = until.difference(DateTime.now()).inSeconds; + return remaining < 0 ? 0 : remaining; + } + + String? get lastError => _lastError; + + Future manualLoadAd() async { + if (!MonetizationConfig.mobileAdsRuntimeSupported) { + return const RewardedAdResult( + success: false, + message: 'Rewarded ads are available only in Android/iOS builds.', + ); + } + if (isInCooldown) { + return RewardedAdResult( + success: false, + message: 'Please wait ${cooldownSeconds}s before trying again.', + ); + } + if (isReady) { + return const RewardedAdResult( + success: true, + message: 'Ad is ready. Tap again to watch.', + ); + } + if (_loading) { + return const RewardedAdResult( + success: false, + message: 'Ad is loading, please wait.', + ); + } + await loadAd(); + return const RewardedAdResult( + success: true, + message: 'Requesting rewarded ad from Google.', + ); + } + + Future loadAd() async { + if (!MonetizationConfig.mobileAdsRuntimeSupported || + _loading || + isReady || + isInCooldown) { + return; + } + _loading = true; + _lastError = null; + notifyListeners(); + try { + await RewardedAd.load( + adUnitId: MonetizationConfig.rewardedAdUnitId, + request: const AdRequest(nonPersonalizedAds: true), + rewardedAdLoadCallback: RewardedAdLoadCallback( + onAdLoaded: (ad) { + _rewardedAd = ad; + _loading = false; + _retryCount = 0; + _lastError = null; + _configureFullScreenCallbacks(ad); + notifyListeners(); + }, + onAdFailedToLoad: (error) { + _rewardedAd = null; + _loading = false; + _handleLoadFailure(error.message); + }, + ), + ); + } catch (error) { + _loading = false; + _handleLoadFailure(error.toString()); + } + } + + Future showRewardedAd() async { + final ad = _rewardedAd; + if (ad == null) { + return const RewardedAdResult( + success: false, + message: 'Rewarded ad is not ready yet.', + ); + } + _rewardedAd = null; + _showing = true; + notifyListeners(); + try { + await ad.show( + onUserEarnedReward: (_, reward) { + final minutes = reward.amount.toInt() > 0 + ? reward.amount.toInt() + : MonetizationConfig.rewardAdFreeMinutes; + unawaited( + _monetizationController.grantTemporaryAdFreeTime( + Duration(minutes: minutes), + ), + ); + }, + ); + return const RewardedAdResult( + success: true, + message: 'Ad opened. Reward applies after completion.', + ); + } catch (error) { + _showing = false; + _lastError = error.toString(); + notifyListeners(); + return RewardedAdResult( + success: false, + message: 'Could not show rewarded ad: $error', + ); + } + } + + void _configureFullScreenCallbacks(RewardedAd ad) { + ad.fullScreenContentCallback = FullScreenContentCallback( + onAdDismissedFullScreenContent: (ad) { + ad.dispose(); + _showing = false; + notifyListeners(); + Future.delayed(const Duration(seconds: 2), () { + if (!_showing && !isReady) { + unawaited(loadAd()); + } + }); + }, + onAdFailedToShowFullScreenContent: (ad, error) { + ad.dispose(); + _showing = false; + _handleLoadFailure(error.message); + }, + ); + } + + void _handleLoadFailure(String message) { + _retryCount += 1; + _lastError = message; + if (_retryCount >= 3) { + _cooldownUntil = DateTime.now().add(const Duration(seconds: 60)); + _cooldownTimer?.cancel(); + _cooldownTimer = Timer(const Duration(seconds: 60), () { + _cooldownUntil = null; + notifyListeners(); + }); + _retryCount = 0; + } + notifyListeners(); + } + + @override + void dispose() { + _cooldownTimer?.cancel(); + _rewardedAd?.dispose(); + super.dispose(); + } +} diff --git a/lib/monetization/store_purchase_service.dart b/lib/monetization/store_purchase_service.dart new file mode 100644 index 0000000..212c420 --- /dev/null +++ b/lib/monetization/store_purchase_service.dart @@ -0,0 +1,200 @@ +import 'dart:async'; + +import 'package:androidircx/monetization/monetization_config.dart'; +import 'package:androidircx/monetization/monetization_controller.dart'; +import 'package:flutter/foundation.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; + +class StorePurchaseService extends ChangeNotifier { + StorePurchaseService({ + required MonetizationController monetizationController, + InAppPurchase? store, + }) : _monetizationController = monetizationController, + _store = store; + + final MonetizationController _monetizationController; + final InAppPurchase? _store; + StreamSubscription>? _purchaseSubscription; + + bool _initialized = false; + bool _storeAvailable = false; + bool _loadingProducts = false; + bool _restoring = false; + String? _pendingProductId; + String? _statusMessage; + List _products = const []; + Set _notFoundProductIds = const {}; + + bool get initialized => _initialized; + bool get storeAvailable => _storeAvailable; + bool get loadingProducts => _loadingProducts; + bool get restoring => _restoring; + String? get pendingProductId => _pendingProductId; + String? get statusMessage => _statusMessage; + List get products => _products; + Set get notFoundProductIds => _notFoundProductIds; + InAppPurchase get _effectiveStore => _store ?? InAppPurchase.instance; + + Future initialize() async { + if (_initialized) { + return; + } + await _monetizationController.initialize(); + _initialized = true; + + if (!MonetizationConfig.storeRuntimeSupported) { + _storeAvailable = false; + _statusMessage = 'Purchases are available only in mobile store builds.'; + notifyListeners(); + return; + } + + final store = _effectiveStore; + _purchaseSubscription = store.purchaseStream.listen( + _handlePurchaseUpdates, + onError: (Object error) { + _pendingProductId = null; + _statusMessage = 'Purchase update failed: $error'; + notifyListeners(); + }, + ); + + try { + _storeAvailable = await store.isAvailable(); + if (_storeAvailable) { + await loadProducts(); + } else { + _statusMessage = 'Google Play Billing is not available on this device.'; + } + } catch (error) { + _storeAvailable = false; + _statusMessage = 'Could not initialize purchases: $error'; + } + notifyListeners(); + } + + Future loadProducts() async { + if (!_storeAvailable || _loadingProducts) { + return; + } + _loadingProducts = true; + _statusMessage = null; + notifyListeners(); + try { + final response = await _effectiveStore.queryProductDetails( + MonetizationConfig.productIds, + ); + _products = response.productDetails; + _notFoundProductIds = response.notFoundIDs.toSet(); + _statusMessage = response.error?.message; + } catch (error) { + _statusMessage = 'Could not load Play products: $error'; + } finally { + _loadingProducts = false; + notifyListeners(); + } + } + + ProductDetails? productDetailsFor(String productId) { + for (final product in _products) { + if (product.id == productId) { + return product; + } + } + return null; + } + + Future buyProduct(String productId) async { + await initialize(); + if (!_storeAvailable) { + _statusMessage = 'Google Play Billing is not available.'; + notifyListeners(); + return; + } + final product = productDetailsFor(productId); + if (product == null) { + _statusMessage = 'Create and activate $productId in Play Console first.'; + notifyListeners(); + return; + } + + _pendingProductId = productId; + _statusMessage = null; + notifyListeners(); + try { + final started = await _effectiveStore.buyNonConsumable( + purchaseParam: PurchaseParam(productDetails: product), + ); + if (!started) { + _pendingProductId = null; + _statusMessage = 'Purchase flow did not start.'; + notifyListeners(); + } + } catch (error) { + _pendingProductId = null; + _statusMessage = 'Purchase failed: $error'; + notifyListeners(); + } + } + + Future restorePurchases() async { + await initialize(); + if (!_storeAvailable) { + _statusMessage = 'Google Play Billing is not available.'; + notifyListeners(); + return; + } + _restoring = true; + _statusMessage = null; + notifyListeners(); + try { + await _effectiveStore.restorePurchases(); + _statusMessage = 'Restore requested. Google Play will return purchases.'; + } catch (error) { + _statusMessage = 'Restore failed: $error'; + } finally { + _restoring = false; + notifyListeners(); + } + } + + Future _handlePurchaseUpdates(List purchases) async { + for (final purchase in purchases) { + if (purchase.status == PurchaseStatus.pending) { + _pendingProductId = purchase.productID; + } else if (purchase.status == PurchaseStatus.purchased || + purchase.status == PurchaseStatus.restored) { + if (MonetizationConfig.productIds.contains(purchase.productID)) { + await _monetizationController.processPurchase( + purchase.productID, + purchase.verificationData.serverVerificationData, + ); + _statusMessage = purchase.status == PurchaseStatus.restored + ? 'Purchase restored.' + : 'Purchase complete.'; + } + if (purchase.pendingCompletePurchase) { + await _effectiveStore.completePurchase(purchase); + } + _pendingProductId = null; + } else if (purchase.status == PurchaseStatus.error) { + _pendingProductId = null; + _statusMessage = + purchase.error?.message ?? 'Purchase failed. Please try again.'; + if (purchase.pendingCompletePurchase) { + await _effectiveStore.completePurchase(purchase); + } + } else if (purchase.status == PurchaseStatus.canceled) { + _pendingProductId = null; + _statusMessage = 'Purchase canceled.'; + } + } + notifyListeners(); + } + + @override + void dispose() { + unawaited(_purchaseSubscription?.cancel()); + super.dispose(); + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 607c9c3..a6d1c4d 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -11,11 +11,13 @@ import firebase_app_check import firebase_core import firebase_crashlytics import flutter_secure_storage_darwin +import in_app_purchase_storekit import in_app_review import local_auth_darwin import shared_preferences_foundation import url_launcher_macos import video_player_avfoundation +import webview_flutter_wkwebview func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) @@ -24,9 +26,11 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseCrashlyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCrashlyticsPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) + InAppPurchasePlugin.register(with: registry.registrar(forPlugin: "InAppPurchasePlugin")) InAppReviewPlugin.register(with: registry.registrar(forPlugin: "InAppReviewPlugin")) LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) VideoPlayerPlugin.register(with: registry.registrar(forPlugin: "VideoPlayerPlugin")) + WebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "WebViewFlutterPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index 3008169..d6966a1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -472,6 +472,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.3" + google_mobile_ads: + dependency: "direct main" + description: + name: google_mobile_ads + sha256: "8094e1ace8b0da33fe79027ca6959763c96a28855025cb7c8ec60838e1d56ed8" + url: "https://pub.dev" + source: hosted + version: "9.1.0" graphs: dependency: transitive description: @@ -584,6 +592,38 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.2" + in_app_purchase: + dependency: "direct main" + description: + name: in_app_purchase + sha256: "0e9510b80b0074e89ab0a8e0fc901439b779dc9ae575ab8d419253c6e1627716" + url: "https://pub.dev" + source: hosted + version: "3.3.0" + in_app_purchase_android: + dependency: transitive + description: + name: in_app_purchase_android + sha256: c04e2cad0470fc868cb0ea06477648f003220b1b6612909db83528ca83ac0905 + url: "https://pub.dev" + source: hosted + version: "0.5.2" + in_app_purchase_platform_interface: + dependency: transitive + description: + name: in_app_purchase_platform_interface + sha256: "0b0076cac8ce4fa7048f01e76af8b123aeb6a7c4e0dea2a5206d6664454f3e36" + url: "https://pub.dev" + source: hosted + version: "1.4.1" + in_app_purchase_storekit: + dependency: transitive + description: + name: in_app_purchase_storekit + sha256: "9602e249a0e30351f047d5715957f27709ed7b42f631fba8941dcad51489932a" + url: "https://pub.dev" + source: hosted + version: "0.4.11+1" in_app_review: dependency: "direct main" description: @@ -1269,6 +1309,38 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.3" + webview_flutter: + dependency: transitive + description: + name: webview_flutter + sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111 + url: "https://pub.dev" + source: hosted + version: "4.14.1" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: b98656fa4461f8cc05c48a778b4d4883e60ec63e1778348f363f9bb9a477745d + url: "https://pub.dev" + source: hosted + version: "4.14.0" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04" + url: "https://pub.dev" + source: hosted + version: "2.15.1" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: c879dd64b87c452aa84381b244d5469da57ba7e8cca6884c7b1e0d406372c12d + url: "https://pub.dev" + source: hosted + version: "3.26.0" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 91c5dc9..7586bdc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.6+9 +version: 1.0.9+13 environment: sdk: ^3.11.1 @@ -54,6 +54,8 @@ dependencies: firebase_analytics: ^12.4.6 firebase_crashlytics: ^5.2.7 firebase_app_check: ^0.4.6 + google_mobile_ads: ^9.1.0 + in_app_purchase: ^3.3.0 dev_dependencies: flutter_test: diff --git a/test/chat_session_controller_test.dart b/test/chat_session_controller_test.dart index 3489b39..a1e539a 100644 --- a/test/chat_session_controller_test.dart +++ b/test/chat_session_controller_test.dart @@ -4762,6 +4762,13 @@ void main() { controller.activeChannelUsers, containsAll(['alice', 'bob']), ); + final details = controller.activeChannelUserDetails; + final alice = details.firstWhere((entry) => entry.nick == 'alice'); + final bob = details.firstWhere((entry) => entry.nick == 'bob'); + expect(alice.prefix, '@'); + expect(alice.statusRank, channelUserStatusRank('@')); + expect(bob.prefix, '+'); + expect(bob.statusRank, channelUserStatusRank('+')); controller.dispose(); }, diff --git a/test/monetization_controller_test.dart b/test/monetization_controller_test.dart new file mode 100644 index 0000000..fa6a937 --- /dev/null +++ b/test/monetization_controller_test.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; + +import 'package:androidircx/monetization/monetization_config.dart'; +import 'package:androidircx/monetization/monetization_controller.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('persists permanent no-ads purchases and verification tokens', () async { + final controller = MonetizationController(); + await controller.initialize(); + + expect(controller.hasNoAds, isFalse); + + final processed = await controller.processPurchase( + MonetizationConfig.productRemoveAds, + 'token-1', + ); + + expect(processed, isTrue); + expect(controller.hasNoAds, isTrue); + expect( + controller.hasPurchased(MonetizationConfig.productRemoveAds), + isTrue, + ); + expect( + await controller.getPurchaseToken(MonetizationConfig.productRemoveAds), + 'token-1', + ); + + controller.dispose(); + + final restored = MonetizationController(); + await restored.initialize(); + + expect(restored.hasNoAds, isTrue); + expect(restored.hasPurchased(MonetizationConfig.productRemoveAds), isTrue); + + restored.dispose(); + }); + + test('expires rewarded ad-free time across restarts', () async { + SharedPreferences.setMockInitialValues({ + 'androidircx.monetization.adFreeTime': jsonEncode({ + 'remainingMs': const Duration(minutes: 1).inMilliseconds, + 'lastUpdated': DateTime.now() + .subtract(const Duration(minutes: 2)) + .millisecondsSinceEpoch, + }), + }); + + final controller = MonetizationController(); + await controller.initialize(); + + expect(controller.hasTemporaryAdFreeTime, isFalse); + + controller.dispose(); + }); + + test('grants and resets temporary ad-free time', () async { + final controller = MonetizationController(); + await controller.initialize(); + + await controller.grantTemporaryAdFreeTime(const Duration(minutes: 1)); + + expect(controller.hasTemporaryAdFreeTime, isTrue); + expect(controller.adFreeTimeFormatted, isNot('0s')); + + await controller.resetTemporaryAdFreeTime(); + + expect(controller.hasTemporaryAdFreeTime, isFalse); + expect(controller.adFreeTimeFormatted, '0s'); + + controller.dispose(); + }); +} diff --git a/test/monetization_settings_test.dart b/test/monetization_settings_test.dart new file mode 100644 index 0000000..e369d85 --- /dev/null +++ b/test/monetization_settings_test.dart @@ -0,0 +1,53 @@ +import 'package:androidircx/features/settings/presentation/settings_screen.dart'; +import 'package:androidircx/monetization/monetization_controller.dart'; +import 'package:androidircx/monetization/monetization_scope.dart'; +import 'package:androidircx/monetization/rewarded_ad_service.dart'; +import 'package:androidircx/monetization/store_purchase_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + testWidgets('settings exposes rewarded ads and no-ads purchase entry', ( + tester, + ) async { + final controller = MonetizationController(); + final rewardedAdService = RewardedAdService( + monetizationController: controller, + ); + final purchaseService = StorePurchaseService( + monetizationController: controller, + ); + + await controller.initialize(); + await tester.pumpWidget( + MonetizationScope( + controller: controller, + rewardedAdService: rewardedAdService, + purchaseService: purchaseService, + child: const MaterialApp(home: SettingsScreen()), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.scrollUntilVisible( + find.text('Premium & ads'), + 500, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + + expect(find.text('Premium & ads'), findsOneWidget); + expect(find.text('Free plan'), findsOneWidget); + expect(find.text('Rewarded ads unavailable here'), findsOneWidget); + expect(find.text('Remove ads permanently'), findsOneWidget); + + purchaseService.dispose(); + rewardedAdService.dispose(); + controller.dispose(); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 60674dc..4ba5df9 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -16,6 +16,7 @@ import 'package:androidircx/dcc/services/dcc_service.dart'; import 'package:androidircx/dcc/services/dcc_socket_backend.dart'; import 'package:androidircx/features/chat/application/chat_session_controller.dart'; import 'package:androidircx/features/chat/application/session_registry.dart'; +import 'package:androidircx/features/chat/presentation/channel_list_screen.dart'; import 'package:androidircx/features/chat/presentation/chat_screen.dart'; import 'package:androidircx/features/chat/presentation/join_channel_dialog.dart'; import 'package:androidircx/features/connections/application/network_list_controller.dart'; @@ -57,6 +58,22 @@ class _FakeTransport implements IrcTransport { } } +bool _spanTreeContainsStyle( + InlineSpan span, + bool Function(TextStyle? style) predicate, +) { + if (span is TextSpan) { + if (predicate(span.style)) { + return true; + } + return span.children?.any( + (child) => _spanTreeContainsStyle(child, predicate), + ) ?? + false; + } + return false; +} + class _FakeDccConnection implements DccSocketConnection { final StreamController> _controller = StreamController>.broadcast(); @@ -433,8 +450,10 @@ void main() { await tester.tap(find.text('Libera')); await tester.pumpAndSettle(); - expect(controller.networks.any((network) => network.name == 'Libera'), - isTrue); + expect( + controller.networks.any((network) => network.name == 'Libera'), + isTrue, + ); registry.dispose(); controller.dispose(); @@ -755,10 +774,7 @@ void main() { // IRC help, Support and Release audit were removed from the menu. expect(find.byKey(const Key('settings-help-topic')), findsNothing); expect(find.byKey(const Key('settings-support-topic')), findsNothing); - expect( - find.byKey(const Key('settings-release-audit-topic')), - findsNothing, - ); + expect(find.byKey(const Key('settings-release-audit-topic')), findsNothing); }); testWidgets('shows IRC services quick actions on the server tab', ( @@ -1030,7 +1046,81 @@ void main() { controller.dispose(); }); - testWidgets('shows rich nick details in the channel user drawer', ( + testWidgets( + 'shows grouped searchable nick details in the channel user drawer', + (tester) async { + SharedPreferences.setMockInitialValues({}); + const network = NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.dbase.in.rs', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + ); + final transport = _FakeTransport(); + final controller = ChatSessionController( + network: network, + ircService: IrcService(transportConnector: (_) async => transport), + ); + + await tester.pumpWidget( + MaterialApp(home: ChatScreen(controller: controller)), + ); + await tester.pump(); + + transport.emit( + ':server 005 AndroidIRCX CHANTYPES=#& PREFIX=(qaohv)~&@%+ :supported', + ); + transport.emit( + ':alice!ident@example JOIN #room aliceAccount :Alice Example', + ); + transport.emit( + ':server 353 AndroidIRCX = #room :~owner &admin @alice!ident@example %half +voice regular', + ); + transport.emit(':alice!ident@example AWAY :coffee'); + await tester.pump(); + await tester.pump(); + controller.selectTab( + controller.tabs.firstWhere((tab) => tab.name == '#room').id, + ); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.people_outline)); + await tester.pumpAndSettle(); + + expect(find.text('Search users'), findsOneWidget); + expect(find.text('~ Owners (1)'), findsOneWidget); + expect(find.text('& Admins (1)'), findsOneWidget); + expect(find.text('@ Operators (1)'), findsOneWidget); + expect(find.text('% Half operators (1)'), findsOneWidget); + await tester.drag(find.byType(ListView).last, const Offset(0, -500)); + await tester.pumpAndSettle(); + expect(find.text('+ Voiced (1)'), findsOneWidget); + expect(find.text('Regular (1)'), findsOneWidget); + await tester.drag(find.byType(ListView).last, const Offset(0, 500)); + await tester.pumpAndSettle(); + expect(find.text('alice'), findsOneWidget); + expect(find.textContaining('account: aliceAccount'), findsWidgets); + expect(find.textContaining('away: coffee'), findsOneWidget); + expect(find.textContaining('mode: @'), findsWidgets); + + await tester.enterText( + find.byKey(const ValueKey('channel-user-search')), + 'ali', + ); + await tester.pumpAndSettle(); + + expect(find.text('1 of 6 users'), findsOneWidget); + expect(find.text('@ Operators (1)'), findsOneWidget); + expect(find.text('alice'), findsOneWidget); + expect(find.text('owner'), findsNothing); + + controller.dispose(); + }, + ); + + testWidgets('renders channel list topics with IRC formatting', ( tester, ) async { SharedPreferences.setMockInitialValues({}); @@ -1047,30 +1137,40 @@ void main() { network: network, ircService: IrcService(transportConnector: (_) async => transport), ); + await controller.start(); await tester.pumpWidget( - MaterialApp(home: ChatScreen(controller: controller)), + MaterialApp(home: ChannelListScreen(controller: controller)), ); await tester.pump(); + transport.emit(':server 321 AndroidIRCX Channel :Users Name'); transport.emit( - ':alice!ident@example JOIN #room aliceAccount :Alice Example', - ); - transport.emit(':server 353 AndroidIRCX = #room :@alice!ident@example'); - transport.emit(':alice!ident@example AWAY :coffee'); - await tester.pump(); - await tester.pump(); - controller.selectTab( - controller.tabs.firstWhere((tab) => tab.name == '#room').id, + ':server 322 AndroidIRCX #color 5 :\u000304Red \u0002bold\u0002', ); + transport.emit(':server 323 AndroidIRCX :End of /LIST'); await tester.pump(); - await tester.tap(find.byIcon(Icons.people_outline)); - await tester.pumpAndSettle(); + expect(find.textContaining('\u0003'), findsNothing); + expect(find.textContaining('Red bold'), findsWidgets); - expect(find.text('alice'), findsOneWidget); - expect(find.textContaining('account: aliceAccount'), findsWidgets); - expect(find.textContaining('away: coffee'), findsOneWidget); + final topicRichText = tester + .widgetList(find.byType(RichText)) + .firstWhere((widget) => widget.text.toPlainText().contains('Red bold')); + expect( + _spanTreeContainsStyle( + topicRichText.text, + (style) => style?.color == const Color(0xFFFF0000), + ), + isTrue, + ); + expect( + _spanTreeContainsStyle( + topicRichText.text, + (style) => style?.fontWeight == FontWeight.bold, + ), + isTrue, + ); controller.dispose(); }); From fad5dc754b9cd09bfd125351586330d6f7acf4c8 Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Tue, 25 Aug 2026 23:26:59 +0200 Subject: [PATCH 02/11] Add dismissable connected banner and reposition premium settings - Allow dismissing the green connected status banner in chat once the connection is stable; error/reconnect states stay visible - Move Premium & ads section right below Connections for free users and down above Help after a permanent no-ads purchase - Bump version to 1.0.10+14 and sync app version constants - Add widget tests for banner dismissal and settings section ordering --- lib/core/app/app_version.dart | 4 +- .../chat/presentation/chat_screen.dart | 56 ++++++- .../presentation/settings_screen.dart | 139 +++++++++++------- pubspec.yaml | 2 +- test/monetization_settings_test.dart | 100 +++++++++++-- test/widget_test.dart | 36 +++++ 6 files changed, 260 insertions(+), 77 deletions(-) diff --git a/lib/core/app/app_version.dart b/lib/core/app/app_version.dart index 508b4ea..795c781 100644 --- a/lib/core/app/app_version.dart +++ b/lib/core/app/app_version.dart @@ -1,5 +1,5 @@ -const appVersionName = '1.0.9'; -const appVersionCode = 13; +const appVersionName = '1.0.10'; +const appVersionCode = 14; const appVersion = '$appVersionName+$appVersionCode'; const ctcpVersionReply = 'AndroidIRCX Flutter v$appVersion'; diff --git a/lib/features/chat/presentation/chat_screen.dart b/lib/features/chat/presentation/chat_screen.dart index 3970d01..665682f 100644 --- a/lib/features/chat/presentation/chat_screen.dart +++ b/lib/features/chat/presentation/chat_screen.dart @@ -87,6 +87,7 @@ class _ChatScreenState extends State { _HistoryKindFilter _messageSearchFilter = _HistoryKindFilter.all; String _nickSearchQuery = ''; IrcMessage? _pendingReplyMessage; + bool _connectedBannerDismissed = false; ChatSessionController get _controller => widget.controller; DccFilePicker get _filePicker => @@ -105,17 +106,41 @@ class _ChatScreenState extends State { @override void initState() { super.initState(); + _controller.addListener(_syncConnectionBannerDismissal); _controller.start(); } + @override + void didUpdateWidget(covariant ChatScreen oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.controller, widget.controller)) { + oldWidget.controller.removeListener(_syncConnectionBannerDismissal); + _connectedBannerDismissed = false; + _controller.addListener(_syncConnectionBannerDismissal); + _controller.start(); + } + } + @override void dispose() { + _controller.removeListener(_syncConnectionBannerDismissal); _composerController.dispose(); _messageSearchController.dispose(); _nickSearchController.dispose(); super.dispose(); } + void _syncConnectionBannerDismissal() { + final snapshot = _controller.connection; + final stableConnected = + snapshot.phase == ConnectionPhase.connected && + _controller.pendingReconnectDelay == null; + if (stableConnected || !_connectedBannerDismissed) { + return; + } + setState(() => _connectedBannerDismissed = false); + } + @override Widget build(BuildContext context) { return CallbackShortcuts( @@ -343,6 +368,11 @@ class _ChatScreenState extends State { _ConnectionBanner( controller: _controller, network: _controller.network, + connectedBannerDismissed: + _connectedBannerDismissed, + onDismissConnectedBanner: () => setState( + () => _connectedBannerDismissed = true, + ), ), if (_messageSearchVisible) _InlineMessageSearchBar( @@ -3848,10 +3878,17 @@ bool _canDownloadAttachment(IrcMessageAttachment attachment) { } class _ConnectionBanner extends StatelessWidget { - const _ConnectionBanner({required this.controller, required this.network}); + const _ConnectionBanner({ + required this.controller, + required this.network, + required this.connectedBannerDismissed, + required this.onDismissConnectedBanner, + }); final ChatSessionController controller; final NetworkConfig network; + final bool connectedBannerDismissed; + final VoidCallback onDismissConnectedBanner; @override Widget build(BuildContext context) { @@ -3859,13 +3896,16 @@ class _ConnectionBanner extends StatelessWidget { final reconnectDelay = controller.pendingReconnectDelay; final theme = Theme.of(context); final statusColor = _colorForPhase(context, snapshot.phase); + final stableConnected = + snapshot.phase == ConnectionPhase.connected && reconnectDelay == null; - if (snapshot.phase == ConnectionPhase.connected && - reconnectDelay == null && - snapshot.message == null) { + if (stableConnected && + (snapshot.message == null || connectedBannerDismissed)) { return const SizedBox.shrink(); } + final canDismiss = stableConnected && (snapshot.message ?? '').isNotEmpty; + return Container( width: double.infinity, margin: const EdgeInsets.fromLTRB(12, 8, 12, 8), @@ -3888,6 +3928,14 @@ class _ConnectionBanner extends StatelessWidget { style: theme.textTheme.titleSmall, ), ), + if (canDismiss) + IconButton( + key: const Key('connection-banner-dismiss'), + onPressed: onDismissConnectedBanner, + icon: const Icon(Icons.close), + tooltip: 'Dismiss connection message', + visualDensity: VisualDensity.compact, + ), ], ), const SizedBox(height: 6), diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index 2e015de..3149f35 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -62,6 +62,8 @@ class _SettingsScreenState extends State { late final SettingsRepository _repository; AppSettingsController? _settingsController; AppSettings _settings = const AppSettings(); + MonetizationController? _monetizationController; + bool? _lastHasNoAds; bool _isLoading = true; bool _didResolveController = false; @@ -77,6 +79,7 @@ class _SettingsScreenState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); + _syncMonetizationController(); if (_didResolveController) { return; } @@ -96,6 +99,7 @@ class _SettingsScreenState extends State { @override void dispose() { _settingsController?.removeListener(_syncFromController); + _monetizationController?.removeListener(_handleMonetizationChanged); _dccDownloadDirectoryController.dispose(); _mediaDownloadDirectoryController.dispose(); _customThemeController.dispose(); @@ -146,6 +150,9 @@ class _SettingsScreenState extends State { ], ), const SizedBox(height: 12), + if (monetizationScope != null && + !monetizationScope.controller.hasNoAds) + ..._premiumAdsSettingsSection(monetizationScope), _SettingsSection( title: 'Appearance', children: [ @@ -443,58 +450,6 @@ class _SettingsScreenState extends State { ], ), const SizedBox(height: 12), - if (monetizationScope != null) ...[ - _SettingsSection( - title: 'Premium & ads', - children: [ - _MonetizationStatusTile( - controller: monetizationScope.controller, - ), - const Divider(height: 1), - AnimatedBuilder( - animation: Listenable.merge([ - monetizationScope.controller, - monetizationScope.rewardedAdService, - ]), - builder: (context, _) { - final rewarded = - monetizationScope.rewardedAdService; - final canRequestAd = - MonetizationConfig.mobileAdsRuntimeSupported && - !rewarded.isLoading && - !rewarded.isShowing && - !rewarded.isInCooldown; - return ListTile( - leading: const Icon(Icons.play_circle_outline), - title: Text(_watchAdTitle(monetizationScope)), - subtitle: Text( - _watchAdSubtitle(monetizationScope), - ), - trailing: FilledButton( - onPressed: canRequestAd - ? () => _handleWatchAd(monetizationScope) - : null, - child: Text( - rewarded.isReady ? 'Watch' : 'Load', - ), - ), - ); - }, - ), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.workspace_premium_outlined), - title: const Text('Remove ads permanently'), - subtitle: const Text( - 'Create matching Play products, then sell ' - 'one-time no-ads upgrades here.', - ), - onTap: () => _openPurchaseScreen(monetizationScope), - ), - ], - ), - const SizedBox(height: 12), - ], _SettingsSection( title: 'Security', children: [ @@ -817,6 +772,9 @@ class _SettingsScreenState extends State { ], ), const SizedBox(height: 12), + if (monetizationScope != null && + monetizationScope.controller.hasNoAds) + ..._premiumAdsSettingsSection(monetizationScope), _SettingsSection( title: 'Help', children: [ @@ -905,6 +863,28 @@ class _SettingsScreenState extends State { }); } + void _syncMonetizationController() { + final controller = MonetizationScope.maybeOf(context)?.controller; + if (identical(_monetizationController, controller)) { + return; + } + _monetizationController?.removeListener(_handleMonetizationChanged); + _monetizationController = controller; + _lastHasNoAds = controller?.hasNoAds; + controller?.addListener(_handleMonetizationChanged); + } + + void _handleMonetizationChanged() { + final hasNoAds = _monetizationController?.hasNoAds; + if (hasNoAds == _lastHasNoAds) { + return; + } + _lastHasNoAds = hasNoAds; + if (mounted) { + setState(() {}); + } + } + void _syncTextControllers(AppSettings settings) { _setControllerText( _dccDownloadDirectoryController, @@ -1128,8 +1108,8 @@ class _SettingsScreenState extends State { messenger.showSnackBar(SnackBar(content: Text(result.message))); } - Future _openPurchaseScreen(MonetizationScope scope) { - return Navigator.of(context).push( + Future _openPurchaseScreen(MonetizationScope scope) async { + await Navigator.of(context).push( MaterialPageRoute( builder: (_) => PurchaseScreen( monetizationController: scope.controller, @@ -1137,6 +1117,57 @@ class _SettingsScreenState extends State { ), ), ); + if (mounted) { + setState(() {}); + } + } + + List _premiumAdsSettingsSection(MonetizationScope monetizationScope) { + return [ + _SettingsSection( + title: 'Premium & ads', + children: [ + _MonetizationStatusTile(controller: monetizationScope.controller), + const Divider(height: 1), + AnimatedBuilder( + animation: Listenable.merge([ + monetizationScope.controller, + monetizationScope.rewardedAdService, + ]), + builder: (context, _) { + final rewarded = monetizationScope.rewardedAdService; + final canRequestAd = + MonetizationConfig.mobileAdsRuntimeSupported && + !rewarded.isLoading && + !rewarded.isShowing && + !rewarded.isInCooldown; + return ListTile( + leading: const Icon(Icons.play_circle_outline), + title: Text(_watchAdTitle(monetizationScope)), + subtitle: Text(_watchAdSubtitle(monetizationScope)), + trailing: FilledButton( + onPressed: canRequestAd + ? () => _handleWatchAd(monetizationScope) + : null, + child: Text(rewarded.isReady ? 'Watch' : 'Load'), + ), + ); + }, + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.workspace_premium_outlined), + title: const Text('Remove ads permanently'), + subtitle: const Text( + 'Create matching Play products, then sell ' + 'one-time no-ads upgrades here.', + ), + onTap: () => _openPurchaseScreen(monetizationScope), + ), + ], + ), + const SizedBox(height: 12), + ]; } String _watchAdTitle(MonetizationScope scope) { diff --git a/pubspec.yaml b/pubspec.yaml index 7586bdc..78f7f4d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.9+13 +version: 1.0.10+14 environment: sdk: ^3.11.1 diff --git a/test/monetization_settings_test.dart b/test/monetization_settings_test.dart index e369d85..a6bed26 100644 --- a/test/monetization_settings_test.dart +++ b/test/monetization_settings_test.dart @@ -1,4 +1,5 @@ import 'package:androidircx/features/settings/presentation/settings_screen.dart'; +import 'package:androidircx/monetization/monetization_config.dart'; import 'package:androidircx/monetization/monetization_controller.dart'; import 'package:androidircx/monetization/monetization_scope.dart'; import 'package:androidircx/monetization/rewarded_ad_service.dart'; @@ -12,18 +13,21 @@ void main() { SharedPreferences.setMockInitialValues({}); }); - testWidgets('settings exposes rewarded ads and no-ads purchase entry', ( - tester, - ) async { - final controller = MonetizationController(); - final rewardedAdService = RewardedAdService( - monetizationController: controller, - ); - final purchaseService = StorePurchaseService( - monetizationController: controller, - ); + void useTallSettingsViewport(WidgetTester tester) { + tester.view.physicalSize = const Size(1200, 5000); + tester.view.devicePixelRatio = 1.0; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + } - await controller.initialize(); + Future pumpSettingsWithMonetization( + WidgetTester tester, + MonetizationController controller, + RewardedAdService rewardedAdService, + StorePurchaseService purchaseService, + ) async { await tester.pumpWidget( MonetizationScope( controller: controller, @@ -34,17 +38,81 @@ void main() { ); await tester.pump(); await tester.pump(const Duration(milliseconds: 100)); - await tester.scrollUntilVisible( - find.text('Premium & ads'), - 500, - scrollable: find.byType(Scrollable).first, + } + + double textTop(WidgetTester tester, String text) { + return tester.getTopLeft(find.text(text).first).dy; + } + + testWidgets('settings shows premium ads near top before purchase', ( + tester, + ) async { + useTallSettingsViewport(tester); + final controller = MonetizationController(); + final rewardedAdService = RewardedAdService( + monetizationController: controller, + ); + final purchaseService = StorePurchaseService( + monetizationController: controller, + ); + + await controller.initialize(); + await pumpSettingsWithMonetization( + tester, + controller, + rewardedAdService, + purchaseService, ); - await tester.pumpAndSettle(); expect(find.text('Premium & ads'), findsOneWidget); expect(find.text('Free plan'), findsOneWidget); expect(find.text('Rewarded ads unavailable here'), findsOneWidget); expect(find.text('Remove ads permanently'), findsOneWidget); + expect( + textTop(tester, 'Premium & ads'), + greaterThan(textTop(tester, 'Connections')), + ); + expect( + textTop(tester, 'Premium & ads'), + lessThan(textTop(tester, 'Appearance')), + ); + + purchaseService.dispose(); + rewardedAdService.dispose(); + controller.dispose(); + }); + + testWidgets('settings moves premium ads near help after purchase', ( + tester, + ) async { + useTallSettingsViewport(tester); + final controller = MonetizationController(); + final rewardedAdService = RewardedAdService( + monetizationController: controller, + ); + final purchaseService = StorePurchaseService( + monetizationController: controller, + ); + + await controller.initialize(); + await controller.processPurchase( + MonetizationConfig.productRemoveAds, + 'token-1', + ); + await pumpSettingsWithMonetization( + tester, + controller, + rewardedAdService, + purchaseService, + ); + + expect(find.text('Premium & ads'), findsOneWidget); + expect(find.text('Remove Ads active'), findsOneWidget); + expect( + textTop(tester, 'Premium & ads'), + greaterThan(textTop(tester, 'Channels')), + ); + expect(textTop(tester, 'Premium & ads'), lessThan(textTop(tester, 'Help'))); purchaseService.dispose(); rewardedAdService.dispose(); diff --git a/test/widget_test.dart b/test/widget_test.dart index 4ba5df9..9dfb7f3 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -810,6 +810,42 @@ void main() { controller.dispose(); }); + testWidgets('dismisses connected status banner in chat screen', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + const network = NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.dbase.in.rs', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + ); + final transport = _FakeTransport(); + final controller = ChatSessionController( + network: network, + ircService: IrcService(transportConnector: (_) async => transport), + ); + + await tester.pumpWidget( + MaterialApp(home: ChatScreen(controller: controller)), + ); + await tester.pump(); + transport.emit(':server 001 AndroidIRCX :Welcome to DBase'); + await tester.pump(); + await tester.pump(); + + expect(find.byKey(const Key('connection-banner-dismiss')), findsOneWidget); + + await tester.tap(find.byKey(const Key('connection-banner-dismiss'))); + await tester.pump(); + + expect(find.byKey(const Key('connection-banner-dismiss')), findsNothing); + + controller.dispose(); + }); + testWidgets('lists other networks and switches from the chat drawer', ( tester, ) async { From 6b6121ff1ff3e9ec7d0b9ef326f2487e21e9b1f9 Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 28 Aug 2026 09:58:49 +0200 Subject: [PATCH 03/11] Add per-network character encoding for IRC traffic - Decode incoming lines and encode outgoing lines with a per-network charset (ISO-8859-x, Windows-125x, KOI8, GBK, Big5) via enough_convert - Optional prefer-UTF-8 mode decodes UTF-8 lines first and falls back to the legacy charset per line; sends stay UTF-8 in that mode - Split socket bytes into lines before decoding so multi-byte characters never straddle chunk boundaries - Text encoding picker + fallback toggle in the network form --- lib/core/models/network_config.dart | 20 ++ .../application/network_list_controller.dart | 7 +- .../presentation/network_form_screen.dart | 49 ++++ .../presentation/network_list_screen.dart | 2 + lib/irc/encoding/irc_encoding.dart | 181 ++++++++++++++ .../services/irc_transport_connector_io.dart | 44 +++- pubspec.lock | 8 + pubspec.yaml | 1 + test/irc_encoding_test.dart | 226 ++++++++++++++++++ 9 files changed, 530 insertions(+), 8 deletions(-) create mode 100644 lib/irc/encoding/irc_encoding.dart create mode 100644 test/irc_encoding_test.dart diff --git a/lib/core/models/network_config.dart b/lib/core/models/network_config.dart index 09fb41b..197d1bd 100644 --- a/lib/core/models/network_config.dart +++ b/lib/core/models/network_config.dart @@ -37,6 +37,8 @@ class NetworkConfig { this.profileLabel, this.profileGroup, this.identityProfileId, + this.encoding = 'utf-8', + this.encodingUtf8Fallback = false, }); final String id; @@ -76,6 +78,16 @@ class NetworkConfig { /// SASL account override this network's identity on connect. final String? identityProfileId; + /// Character encoding for wire text (canonical lower-case label, e.g. + /// 'utf-8', 'windows-1251'). IRC has no in-band charset negotiation, so + /// legacy networks may need a non-UTF-8 charset. + final String encoding; + + /// When true and [encoding] is a legacy charset, incoming lines are decoded + /// as UTF-8 first (falling back to the legacy charset per line) and + /// outgoing lines are sent as UTF-8. + final bool encodingUtf8Fallback; + NetworkConfig copyWith({ String? id, String? name, @@ -106,6 +118,8 @@ class NetworkConfig { String? profileLabel, String? profileGroup, String? identityProfileId, + String? encoding, + bool? encodingUtf8Fallback, }) { return NetworkConfig( id: id ?? this.id, @@ -137,6 +151,8 @@ class NetworkConfig { profileLabel: profileLabel ?? this.profileLabel, profileGroup: profileGroup ?? this.profileGroup, identityProfileId: identityProfileId ?? this.identityProfileId, + encoding: encoding ?? this.encoding, + encodingUtf8Fallback: encodingUtf8Fallback ?? this.encodingUtf8Fallback, ); } @@ -171,6 +187,8 @@ class NetworkConfig { 'profileLabel': profileLabel, 'profileGroup': profileGroup, 'identityProfileId': identityProfileId, + 'encoding': encoding, + 'encodingUtf8Fallback': encodingUtf8Fallback, }; } @@ -227,6 +245,8 @@ class NetworkConfig { profileLabel: _nonEmptyString(json['profileLabel']), profileGroup: _nonEmptyString(json['profileGroup']), identityProfileId: _nonEmptyString(json['identityProfileId']), + encoding: _nonEmptyString(json['encoding'])?.toLowerCase() ?? 'utf-8', + encodingUtf8Fallback: (json['encodingUtf8Fallback'] as bool?) ?? false, ); } diff --git a/lib/features/connections/application/network_list_controller.dart b/lib/features/connections/application/network_list_controller.dart index 04c521a..ff48d38 100644 --- a/lib/features/connections/application/network_list_controller.dart +++ b/lib/features/connections/application/network_list_controller.dart @@ -12,8 +12,7 @@ class NetworkListController extends ChangeNotifier { CertificateStore? certificateStore, }) : _repository = repository, _certificateStore = - certificateStore ?? - CertificateStore(FlutterSecureSecretStorage()); + certificateStore ?? CertificateStore(FlutterSecureSecretStorage()); final NetworkRepository _repository; final CertificateStore _certificateStore; @@ -62,6 +61,8 @@ class NetworkListController extends ChangeNotifier { String? clientPrivateKeyPem, String? clientPkcs12Base64, String? clientKeyPassphrase, + String encoding = 'utf-8', + bool encodingUtf8Fallback = false, String? networkId, }) async { final network = NetworkConfig( @@ -97,6 +98,8 @@ class NetworkListController extends ChangeNotifier { proxyPassword: (proxyPassword ?? '').trim().isEmpty ? null : proxyPassword, + encoding: encoding, + encodingUtf8Fallback: encodingUtf8Fallback, ); await _repository.saveNetwork(network); diff --git a/lib/features/connections/presentation/network_form_screen.dart b/lib/features/connections/presentation/network_form_screen.dart index 19052e9..8c3c69a 100644 --- a/lib/features/connections/presentation/network_form_screen.dart +++ b/lib/features/connections/presentation/network_form_screen.dart @@ -7,6 +7,7 @@ import 'package:androidircx/core/models/network_config.dart'; import 'package:androidircx/core/storage/identity_profile_repository.dart'; import 'package:androidircx/dcc/services/dcc_file_picker.dart'; import 'package:androidircx/features/connections/data/pem_bundle.dart'; +import 'package:androidircx/irc/encoding/irc_encoding.dart'; import 'package:flutter/material.dart'; class NetworkFormResult { @@ -40,6 +41,8 @@ class NetworkFormResult { this.clientPrivateKeyPem, this.clientPkcs12Base64, this.clientKeyPassphrase, + this.encoding = defaultIrcEncoding, + this.encodingUtf8Fallback = false, }); final String name; @@ -71,6 +74,8 @@ class NetworkFormResult { final String? clientPrivateKeyPem; final String? clientPkcs12Base64; final String? clientKeyPassphrase; + final String encoding; + final bool encodingUtf8Fallback; } class NetworkFormScreen extends StatefulWidget { @@ -121,6 +126,8 @@ class _NetworkFormScreenState extends State { late final TextEditingController _proxyUsernameController; late final TextEditingController _proxyPasswordController; late bool _useTls; + late String _encoding; + late bool _encodingUtf8Fallback; late bool _autoConnect; late SaslMechanism _saslMechanism; late ServiceAuthFallback _serviceAuthFallback; @@ -202,6 +209,8 @@ class _NetworkFormScreenState extends State { _serviceAuthFallback = initial?.serviceAuthFallback ?? ServiceAuthFallback.disabled; _proxyType = initial?.proxyType ?? IrcProxyType.none; + _encoding = normalizeIrcEncoding(initial?.encoding); + _encodingUtf8Fallback = initial?.encodingUtf8Fallback ?? false; } Future _loadProfiles() async { @@ -590,6 +599,43 @@ class _NetworkFormScreenState extends State { value: _useTls, onChanged: (value) => setState(() => _useTls = value), ), + const SizedBox(height: 8), + DropdownButtonFormField( + key: const Key('network-form-encoding'), + initialValue: _encoding, + decoration: const InputDecoration( + labelText: 'Text encoding', + helperText: + 'UTF-8 works for modern networks; pick a legacy ' + 'charset only if text shows garbled characters.', + ), + items: [ + for (final option in supportedIrcEncodings) + DropdownMenuItem( + value: option.label, + child: Text(option.name), + ), + ], + onChanged: (value) { + if (value == null) { + return; + } + setState(() => _encoding = value); + }, + ), + if (_encoding != defaultIrcEncoding) + SwitchListTile( + key: const Key('network-form-encoding-utf8-fallback'), + contentPadding: EdgeInsets.zero, + title: const Text('Prefer UTF-8 (fallback to encoding)'), + subtitle: const Text( + 'Decode UTF-8 lines normally and use the legacy ' + 'charset only for non-UTF-8 lines. Sends UTF-8.', + ), + value: _encodingUtf8Fallback, + onChanged: (value) => + setState(() => _encodingUtf8Fallback = value), + ), SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text('Auto connect'), @@ -785,6 +831,9 @@ class _NetworkFormScreenState extends State { clientPrivateKeyPem: _clientKeyController.text, clientPkcs12Base64: _clientPkcs12Base64, clientKeyPassphrase: _clientKeyPassphraseController.text, + encoding: _encoding, + encodingUtf8Fallback: + _encoding != defaultIrcEncoding && _encodingUtf8Fallback, ), ); } diff --git a/lib/features/connections/presentation/network_list_screen.dart b/lib/features/connections/presentation/network_list_screen.dart index 53dccdb..915dc30 100644 --- a/lib/features/connections/presentation/network_list_screen.dart +++ b/lib/features/connections/presentation/network_list_screen.dart @@ -159,6 +159,8 @@ class NetworkListScreen extends StatelessWidget { clientPrivateKeyPem: result.clientPrivateKeyPem, clientPkcs12Base64: result.clientPkcs12Base64, clientKeyPassphrase: result.clientKeyPassphrase, + encoding: result.encoding, + encodingUtf8Fallback: result.encodingUtf8Fallback, networkId: initialValue?.id, ); } diff --git a/lib/irc/encoding/irc_encoding.dart b/lib/irc/encoding/irc_encoding.dart new file mode 100644 index 0000000..87515b7 --- /dev/null +++ b/lib/irc/encoding/irc_encoding.dart @@ -0,0 +1,181 @@ +import 'dart:convert'; + +import 'package:enough_convert/enough_convert.dart'; + +/// Character-encoding support for IRC traffic. +/// +/// IRC is a byte protocol with no in-band charset negotiation, so different +/// networks/users send text in different legacy encodings (ISO-8859-*, +/// Windows-125x, KOI8, ...). Incoming line bytes are decoded and outgoing +/// lines encoded using a per-network encoding, with an optional "prefer +/// UTF-8, fall back to legacy" mode for mixed channels. +/// +/// Decoding is done per complete IRC line (bytes are split on LF before +/// decoding), so no streaming state is needed and multi-byte characters never +/// straddle a chunk boundary — all supported encodings keep 0x0A/0x0D as +/// ASCII control bytes. +const String defaultIrcEncoding = 'utf-8'; + +class IrcEncodingOption { + const IrcEncodingOption(this.label, this.name); + + /// Canonical lower-case label (also what we persist). + final String label; + + /// Human-readable name shown in settings. + final String name; +} + +/// Curated list of encodings offered in the UI, ordered by how common they +/// are on IRC. `utf-8` is the modern default; the rest are legacy charsets +/// still used on older networks and by older clients. +const List supportedIrcEncodings = [ + IrcEncodingOption('utf-8', 'UTF-8 (Unicode)'), + IrcEncodingOption('iso-8859-1', 'Western (ISO-8859-1)'), + IrcEncodingOption('iso-8859-15', 'Western (ISO-8859-15)'), + IrcEncodingOption('windows-1252', 'Western (Windows-1252)'), + IrcEncodingOption('iso-8859-2', 'Central European (ISO-8859-2)'), + IrcEncodingOption('windows-1250', 'Central European (Windows-1250)'), + IrcEncodingOption('windows-1251', 'Cyrillic (Windows-1251)'), + IrcEncodingOption('koi8-r', 'Cyrillic (KOI8-R)'), + IrcEncodingOption('koi8-u', 'Cyrillic (KOI8-U)'), + IrcEncodingOption('iso-8859-5', 'Cyrillic (ISO-8859-5)'), + IrcEncodingOption('iso-8859-7', 'Greek (ISO-8859-7)'), + IrcEncodingOption('windows-1253', 'Greek (Windows-1253)'), + IrcEncodingOption('iso-8859-9', 'Turkish (ISO-8859-9)'), + IrcEncodingOption('windows-1254', 'Turkish (Windows-1254)'), + IrcEncodingOption('iso-8859-13', 'Baltic (ISO-8859-13)'), + IrcEncodingOption('windows-1256', 'Arabic (Windows-1256)'), + IrcEncodingOption('gbk', 'Chinese Simplified (GBK)'), + IrcEncodingOption('big5', 'Chinese Traditional (Big5)'), +]; + +final Set _supportedLabels = supportedIrcEncodings + .map((option) => option.label) + .toSet(); + +/// Normalizes a persisted label to a canonical, lower-case supported form. +String normalizeIrcEncoding(String? label) { + final value = (label ?? defaultIrcEncoding).toLowerCase().trim(); + return _supportedLabels.contains(value) ? value : defaultIrcEncoding; +} + +/// Display name for a label (falls back to the normalized label). +String ircEncodingDisplayName(String? label) { + final normalized = normalizeIrcEncoding(label); + for (final option in supportedIrcEncodings) { + if (option.label == normalized) { + return option.name; + } + } + return normalized; +} + +Encoding? _legacyCodec(String normalizedLabel) { + return switch (normalizedLabel) { + 'iso-8859-1' => const Latin1Codec(allowInvalid: true), + 'iso-8859-2' => const Latin2Codec(allowInvalid: true), + 'iso-8859-5' => const Latin5Codec(allowInvalid: true), + 'iso-8859-7' => const Latin7Codec(allowInvalid: true), + 'iso-8859-9' => const Latin9Codec(allowInvalid: true), + 'iso-8859-13' => const Latin13Codec(allowInvalid: true), + 'iso-8859-15' => const Latin15Codec(allowInvalid: true), + 'windows-1250' => const Windows1250Codec(allowInvalid: true), + 'windows-1251' => const Windows1251Codec(allowInvalid: true), + 'windows-1252' => const Windows1252Codec(allowInvalid: true), + 'windows-1253' => const Windows1253Codec(allowInvalid: true), + 'windows-1254' => const Windows1254Codec(allowInvalid: true), + 'windows-1256' => const Windows1256Codec(allowInvalid: true), + 'koi8-r' => const Koi8rCodec(allowInvalid: true), + 'koi8-u' => const Koi8uCodec(allowInvalid: true), + 'gbk' => const GbkCodec(allowInvalid: true), + 'big5' => const Big5Codec(allowInvalid: true), + _ => null, + }; +} + +/// Decodes the bytes of a single IRC line (without the trailing CRLF). +/// +/// When [utf8Fallback] is true and [encoding] is a legacy charset, the line is +/// decoded as UTF-8 first and only falls back to the legacy charset if the +/// bytes are not valid UTF-8. Ignored for `utf-8`. +String decodeIrcLine( + List bytes, { + String encoding = defaultIrcEncoding, + bool utf8Fallback = false, +}) { + final normalized = normalizeIrcEncoding(encoding); + if (normalized == defaultIrcEncoding) { + return utf8.decode(bytes, allowMalformed: true); + } + if (utf8Fallback) { + try { + return utf8.decode(bytes); + } on FormatException { + // Not valid UTF-8 — decode this line with the legacy charset instead. + } + } + final codec = _legacyCodec(normalized); + if (codec == null) { + return utf8.decode(bytes, allowMalformed: true); + } + return codec.decode(bytes); +} + +/// Encodes an outgoing IRC line for the wire. +/// +/// Returns legacy bytes, or `null` when the caller should use the plain +/// UTF-8 string write path (encoding is `utf-8`, or [utf8Fallback] prefers +/// sending UTF-8). Returning null keeps the common UTF-8 path byte-identical +/// to the previous behavior. +List? encodeIrcLine( + String line, { + String encoding = defaultIrcEncoding, + bool utf8Fallback = false, +}) { + final normalized = normalizeIrcEncoding(encoding); + if (normalized == defaultIrcEncoding || utf8Fallback) { + return null; + } + final codec = _legacyCodec(normalized); + if (codec == null) { + return null; + } + try { + return codec.encode(line); + } on FormatException { + // Unencodable line — fall back to UTF-8 rather than dropping the send. + return null; + } +} + +/// Splits raw socket bytes into per-line byte lists on LF, stripping a +/// trailing CR. All supported encodings keep 0x0A and 0x0D as ASCII control +/// bytes, so line splitting is safe before decoding and decoding a whole line +/// keeps multi-byte characters intact. +class IrcByteLineSplitter { + final List _buffer = []; + + /// Adds [chunk] and returns every complete line's bytes (without CRLF). + List> addChunk(List chunk) { + _buffer.addAll(chunk); + final lines = >[]; + var start = 0; + while (true) { + final newlineIndex = _buffer.indexOf(0x0a, start); + if (newlineIndex == -1) { + break; + } + var end = newlineIndex; + if (end > start && _buffer[end - 1] == 0x0d) { + end -= 1; + } + lines.add(_buffer.sublist(start, end)); + start = newlineIndex + 1; + } + if (start > 0) { + _buffer.removeRange(0, start); + } + return lines; + } +} diff --git a/lib/irc/services/irc_transport_connector_io.dart b/lib/irc/services/irc_transport_connector_io.dart index 2c8244a..93a20bc 100644 --- a/lib/irc/services/irc_transport_connector_io.dart +++ b/lib/irc/services/irc_transport_connector_io.dart @@ -5,6 +5,7 @@ import 'dart:typed_data'; import 'package:androidircx/core/models/network_config.dart'; import 'package:androidircx/core/security/certificate_store.dart'; +import 'package:androidircx/irc/encoding/irc_encoding.dart'; import 'package:androidircx/irc/services/irc_transport.dart'; Future connectDefaultTransport( @@ -50,11 +51,21 @@ class SocketIrcTransport implements IrcTransport { this._socket, { StreamSubscription? subscription, List initialData = const [], - }) { + String encoding = defaultIrcEncoding, + bool encodingUtf8Fallback = false, + }) : _encoding = normalizeIrcEncoding(encoding), + _encodingUtf8Fallback = encodingUtf8Fallback { _byteController = StreamController>(); + final lineSplitter = IrcByteLineSplitter(); lines = _byteController.stream - .transform(utf8.decoder) - .transform(const LineSplitter()) + .expand(lineSplitter.addChunk) + .map( + (lineBytes) => decodeIrcLine( + lineBytes, + encoding: _encoding, + utf8Fallback: _encodingUtf8Fallback, + ), + ) .where((line) => line.isNotEmpty) .asBroadcastStream(); @@ -75,6 +86,8 @@ class SocketIrcTransport implements IrcTransport { } final Socket _socket; + final String _encoding; + final bool _encodingUtf8Fallback; late final StreamController> _byteController; late final StreamSubscription _subscription; @@ -92,7 +105,11 @@ class SocketIrcTransport implements IrcTransport { network, securityContext: securityContext, ); - return SocketIrcTransport._(socket); + return SocketIrcTransport._( + socket, + encoding: network.encoding, + encodingUtf8Fallback: network.encodingUtf8Fallback, + ); } @override @@ -106,7 +123,16 @@ class SocketIrcTransport implements IrcTransport { @override Future sendLine(String line) async { - _socket.write('$line\r\n'); + final legacyBytes = encodeIrcLine( + line, + encoding: _encoding, + utf8Fallback: _encodingUtf8Fallback, + ); + if (legacyBytes != null) { + _socket.add([...legacyBytes, 0x0d, 0x0a]); + } else { + _socket.write('$line\r\n'); + } await _socket.flush(); } @@ -146,7 +172,11 @@ class SocketIrcTransport implements IrcTransport { socket, host: network.host, ); - return SocketIrcTransport._(secureSocket); + return SocketIrcTransport._( + secureSocket, + encoding: network.encoding, + encodingUtf8Fallback: network.encodingUtf8Fallback, + ); } final subscription = reader.takeSubscription(); @@ -154,6 +184,8 @@ class SocketIrcTransport implements IrcTransport { socket, subscription: subscription, initialData: remainingData, + encoding: network.encoding, + encodingUtf8Fallback: network.encodingUtf8Fallback, ); } catch (_) { await reader.cancel(); diff --git a/pubspec.lock b/pubspec.lock index d6966a1..a1fca45 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -225,6 +225,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.34.5" + enough_convert: + dependency: "direct main" + description: + name: enough_convert + sha256: c67d85ca21aaa0648f155907362430701db41f7ec8e6501a58ad9cd9d8569d01 + url: "https://pub.dev" + source: hosted + version: "1.6.0" fake_async: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 78f7f4d..bc209a7 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -56,6 +56,7 @@ dependencies: firebase_app_check: ^0.4.6 google_mobile_ads: ^9.1.0 in_app_purchase: ^3.3.0 + enough_convert: ^1.6.0 dev_dependencies: flutter_test: diff --git a/test/irc_encoding_test.dart b/test/irc_encoding_test.dart new file mode 100644 index 0000000..1e1083a --- /dev/null +++ b/test/irc_encoding_test.dart @@ -0,0 +1,226 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:androidircx/core/models/network_config.dart'; +import 'package:androidircx/irc/encoding/irc_encoding.dart'; +import 'package:androidircx/irc/services/irc_transport_connector_io.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('normalizeIrcEncoding', () { + test('normalizes case and whitespace', () { + expect(normalizeIrcEncoding(' Windows-1250 '), 'windows-1250'); + expect(normalizeIrcEncoding('UTF-8'), 'utf-8'); + }); + + test('falls back to utf-8 for unknown or empty labels', () { + expect(normalizeIrcEncoding('shift_jis'), 'utf-8'); + expect(normalizeIrcEncoding(''), 'utf-8'); + expect(normalizeIrcEncoding(null), 'utf-8'); + }); + + test('display name resolves for supported labels', () { + expect(ircEncodingDisplayName('windows-1251'), 'Cyrillic (Windows-1251)'); + expect(ircEncodingDisplayName('bogus'), 'UTF-8 (Unicode)'); + }); + }); + + group('decodeIrcLine', () { + test('decodes utf-8 by default and tolerates malformed bytes', () { + expect(decodeIrcLine(utf8.encode('Žčć šđ')), 'Žčć šđ'); + // Lone 0xE8 is invalid UTF-8; must not throw. + expect(decodeIrcLine([0x61, 0xE8, 0x62]), contains('a')); + }); + + test('decodes windows-1250 line bytes', () { + // 'čađ' in windows-1250: č=0xE8 a=0x61 đ=0xF0 + final decoded = decodeIrcLine([ + 0xE8, + 0x61, + 0xF0, + ], encoding: 'windows-1250'); + expect(decoded, 'čađ'); + }); + + test('decodes windows-1251 cyrillic line bytes', () { + // 'Жао' in windows-1251: Ж=0xC6 а=0xE0 о=0xEE + final decoded = decodeIrcLine([ + 0xC6, + 0xE0, + 0xEE, + ], encoding: 'windows-1251'); + expect(decoded, 'Жао'); + }); + + test('utf8Fallback decodes valid UTF-8 as UTF-8', () { + final utf8Bytes = utf8.encode('šđž'); + final decoded = decodeIrcLine( + utf8Bytes, + encoding: 'windows-1250', + utf8Fallback: true, + ); + expect(decoded, 'šđž'); + }); + + test('utf8Fallback falls back to legacy for invalid UTF-8', () { + final decoded = decodeIrcLine( + [0xE8, 0x61, 0xF0], + encoding: 'windows-1250', + utf8Fallback: true, + ); + expect(decoded, 'čađ'); + }); + }); + + group('encodeIrcLine', () { + test('returns null for utf-8 (caller uses the string path)', () { + expect(encodeIrcLine('PRIVMSG #a :hi'), isNull); + expect(encodeIrcLine('PRIVMSG #a :hi', encoding: 'utf-8'), isNull); + }); + + test('returns null when utf8Fallback prefers sending UTF-8', () { + expect( + encodeIrcLine('hi', encoding: 'windows-1250', utf8Fallback: true), + isNull, + ); + }); + + test('encodes legacy charsets to legacy bytes', () { + expect(encodeIrcLine('čađ', encoding: 'windows-1250'), [ + 0xE8, + 0x61, + 0xF0, + ]); + expect(encodeIrcLine('Жао', encoding: 'windows-1251'), [ + 0xC6, + 0xE0, + 0xEE, + ]); + }); + + test('round-trips a legacy encode/decode', () { + const original = 'Šta ima, đače?'; + final bytes = encodeIrcLine(original, encoding: 'windows-1250')!; + expect(decodeIrcLine(bytes, encoding: 'windows-1250'), original); + }); + }); + + group('IrcByteLineSplitter', () { + test('splits CRLF and LF lines and strips the CR', () { + final splitter = IrcByteLineSplitter(); + final lines = splitter.addChunk(utf8.encode('a\r\nb\nc')); + expect(lines, [utf8.encode('a'), utf8.encode('b')]); + expect(splitter.addChunk(utf8.encode('\r\n')), [utf8.encode('c')]); + }); + + test('keeps multi-byte characters intact across chunk boundaries', () { + final splitter = IrcByteLineSplitter(); + final bytes = utf8.encode('PRIVMSG #x :šđž\r\n'); + // Feed one byte at a time so the UTF-8 sequences straddle chunks. + final lines = >[]; + for (final byte in bytes) { + lines.addAll(splitter.addChunk([byte])); + } + expect(lines, hasLength(1)); + expect(utf8.decode(lines.single), 'PRIVMSG #x :šđž'); + }); + + test('buffers partial lines until the newline arrives', () { + final splitter = IrcByteLineSplitter(); + expect(splitter.addChunk(utf8.encode('PING :tok')), isEmpty); + expect(splitter.addChunk(utf8.encode('en\r\nNOTICE')), [ + utf8.encode('PING :token'), + ]); + }); + }); + + group('NetworkConfig encoding fields', () { + test('defaults to utf-8 without fallback', () { + const network = NetworkConfig( + id: 'n1', + name: 'Net', + host: 'irc.example.org', + port: 6697, + nickname: 'nick', + ); + expect(network.encoding, 'utf-8'); + expect(network.encodingUtf8Fallback, isFalse); + }); + + test('round-trips through JSON', () { + const network = NetworkConfig( + id: 'n1', + name: 'Net', + host: 'irc.example.org', + port: 6697, + nickname: 'nick', + encoding: 'windows-1250', + encodingUtf8Fallback: true, + ); + final restored = NetworkConfig.fromJson(network.toJson()); + expect(restored.encoding, 'windows-1250'); + expect(restored.encodingUtf8Fallback, isTrue); + }); + + test('missing JSON fields keep utf-8 defaults', () { + final restored = NetworkConfig.fromJson({ + 'id': 'n1', + 'name': 'Net', + 'host': 'irc.example.org', + 'port': 6697, + 'nickname': 'nick', + }); + expect(restored.encoding, 'utf-8'); + expect(restored.encodingUtf8Fallback, isFalse); + }); + }); + + group('SocketIrcTransport encoding', () { + test('decodes incoming legacy bytes and encodes outgoing lines', () async { + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() async => server.close()); + + final serverReceived = []; + final serverDone = server.first.then((client) async { + // 'čao\r\n' in windows-1250. + client.add([0xE8, 0x61, 0x6F, 0x0D, 0x0A]); + await client.flush(); + await for (final chunk in client) { + serverReceived.addAll(chunk); + if (serverReceived.contains(0x0A)) { + break; + } + } + client.destroy(); + }); + + final network = NetworkConfig( + id: 'n1', + name: 'Net', + host: InternetAddress.loopbackIPv4.address, + port: server.port, + nickname: 'nick', + useTls: false, + encoding: 'windows-1250', + ); + + final transport = await SocketIrcTransport.connect(network); + addTearDown(() async => transport.close()); + + final firstLine = transport.lines.first; + await transport.sendLine('PRIVMSG #x :čao'); + expect(await firstLine, 'čao'); + + await serverDone; + // Outgoing 'č' must be one windows-1250 byte (0xE8), not UTF-8. + expect(serverReceived, [ + ...ascii.encode('PRIVMSG #x :'), + 0xE8, + 0x61, + 0x6F, + 0x0D, + 0x0A, + ]); + }); + }); +} From 6333effb1f7f90bc11ebe286bdfe47b6b11f0f0b Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 28 Aug 2026 10:02:54 +0200 Subject: [PATCH 04/11] Add message font family picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New messageFontFamily setting ('system' default) with Android family choices: sans-serif variants, serif, monospace - Replaces the monospace switch in Settings → Appearance; legacy monospaceMessages still applies when family is 'system' and stored settings without the new key migrate to 'monospace' automatically --- lib/app/theme/app_theme.dart | 5 +- lib/core/models/app_settings.dart | 18 ++++++ .../presentation/settings_screen.dart | 60 +++++++++++++++---- test/storage_repositories_test.dart | 24 ++++++++ test/widget_test.dart | 7 ++- 5 files changed, 101 insertions(+), 13 deletions(-) diff --git a/lib/app/theme/app_theme.dart b/lib/app/theme/app_theme.dart index c67729e..4d422ff 100644 --- a/lib/app/theme/app_theme.dart +++ b/lib/app/theme/app_theme.dart @@ -163,7 +163,10 @@ class IrcUiTheme extends ThemeExtension { topic: palette.topic, messageBorder: palette.outline, messageFontSize: 14 * settings.messageFontScale, - messageFontFamily: settings.monospaceMessages ? 'monospace' : null, + messageFontFamily: switch (settings.messageFontFamily) { + 'system' => settings.monospaceMessages ? 'monospace' : null, + final family => family, + }, messagePadding: density.padding, messageSpacing: density.spacing, nickColorMode: settings.nickColorMode, diff --git a/lib/core/models/app_settings.dart b/lib/core/models/app_settings.dart index 6cff832..adf0bb6 100644 --- a/lib/core/models/app_settings.dart +++ b/lib/core/models/app_settings.dart @@ -19,6 +19,7 @@ class AppSettings { this.messageFontScale = 1.0, this.messageDensity = MessageDensity.comfortable, this.monospaceMessages = false, + this.messageFontFamily = 'system', this.nickColorMode = NickColorMode.soft, this.onboardingCompleted = false, this.appLockEnabled = false, @@ -52,7 +53,14 @@ class AppSettings { final String customThemeJson; final double messageFontScale; final MessageDensity messageDensity; + + /// Legacy monospace toggle, kept for stored-settings migration; superseded + /// by [messageFontFamily] whenever that is not 'system'. final bool monospaceMessages; + + /// Message font family: 'system' for the platform default, otherwise an + /// Android family name ('monospace', 'serif', 'sans-serif', ...). + final String messageFontFamily; final NickColorMode nickColorMode; /// Whether the first-run onboarding + consent flow has been completed. @@ -109,6 +117,7 @@ class AppSettings { double? messageFontScale, MessageDensity? messageDensity, bool? monospaceMessages, + String? messageFontFamily, NickColorMode? nickColorMode, bool? onboardingCompleted, bool? appLockEnabled, @@ -149,6 +158,7 @@ class AppSettings { ), messageDensity: messageDensity ?? this.messageDensity, monospaceMessages: monospaceMessages ?? this.monospaceMessages, + messageFontFamily: messageFontFamily ?? this.messageFontFamily, nickColorMode: nickColorMode ?? this.nickColorMode, onboardingCompleted: onboardingCompleted ?? this.onboardingCompleted, appLockEnabled: appLockEnabled ?? this.appLockEnabled, @@ -188,6 +198,7 @@ class AppSettings { 'messageFontScale': messageFontScale, 'messageDensity': messageDensity.name, 'monospaceMessages': monospaceMessages, + 'messageFontFamily': messageFontFamily, 'nickColorMode': nickColorMode.name, 'onboardingCompleted': onboardingCompleted, 'appLockEnabled': appLockEnabled, @@ -241,6 +252,13 @@ class AppSettings { MessageDensity.comfortable, ), monospaceMessages: (json['monospaceMessages'] as bool?) ?? false, + messageFontFamily: + (json['messageFontFamily'] as String?)?.trim().isNotEmpty ?? false + ? (json['messageFontFamily']! as String).trim() + // Migrate the legacy monospace toggle into the font family. + : ((json['monospaceMessages'] as bool?) ?? false) + ? 'monospace' + : 'system', nickColorMode: _enumByName( NickColorMode.values, json['nickColorMode'], diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index 3149f35..00dd817 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -22,6 +22,18 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:local_auth/local_auth.dart'; +/// Message font choices. Values are Android built-in family names ('system' +/// maps to the platform default); no font assets are bundled. +const _messageFontOptions = <({String family, String label})>[ + (family: 'system', label: 'System default'), + (family: 'sans-serif', label: 'Sans-serif'), + (family: 'sans-serif-light', label: 'Sans-serif Light'), + (family: 'sans-serif-medium', label: 'Sans-serif Medium'), + (family: 'sans-serif-condensed', label: 'Sans-serif Condensed'), + (family: 'serif', label: 'Serif'), + (family: 'monospace', label: 'Monospace'), +]; + class SettingsScreen extends StatefulWidget { const SettingsScreen({ super.key, @@ -288,18 +300,34 @@ class _SettingsScreenState extends State { ), ), const Divider(height: 1), - SwitchListTile( - key: const Key('settings-monospace-messages'), - title: const Text('Monospace messages'), + ListTile( + title: const Text('Message font'), subtitle: const Text( - 'Render IRC message bodies in a fixed-width font.', + 'Typeface used for IRC message bodies.', + ), + trailing: DropdownButton( + key: const Key('settings-message-font-family'), + value: _effectiveMessageFontFamily, + onChanged: (value) async { + if (value == null) { + return; + } + await _saveSettings( + _settings.copyWith( + messageFontFamily: value, + monospaceMessages: value == 'monospace', + ), + ); + }, + items: _messageFontOptions + .map( + (option) => DropdownMenuItem( + value: option.family, + child: Text(option.label), + ), + ) + .toList(growable: false), ), - value: _settings.monospaceMessages, - onChanged: (value) async { - await _saveSettings( - _settings.copyWith(monospaceMessages: value), - ); - }, ), const Divider(height: 1), ListTile( @@ -1242,6 +1270,18 @@ class _SettingsScreenState extends State { }; } + /// Current font-family dropdown value, folding the legacy monospace toggle + /// into 'monospace' so old settings show the right selection. + String get _effectiveMessageFontFamily { + final family = _settings.messageFontFamily; + if (family == 'system' && _settings.monospaceMessages) { + return 'monospace'; + } + return _messageFontOptions.any((option) => option.family == family) + ? family + : 'system'; + } + String _labelForNickColorMode(NickColorMode mode) { return switch (mode) { NickColorMode.none => 'Off', diff --git a/test/storage_repositories_test.dart b/test/storage_repositories_test.dart index 9d5537a..77a1dc8 100644 --- a/test/storage_repositories_test.dart +++ b/test/storage_repositories_test.dart @@ -630,9 +630,33 @@ void main() { expect(settings.messageFontScale, 1.2); expect(settings.messageDensity, MessageDensity.compact); expect(settings.monospaceMessages, isTrue); + expect(settings.messageFontFamily, 'system'); expect(settings.nickColorMode, NickColorMode.vivid); }); + test('legacy monospace toggle migrates into the message font family', () { + // Stored settings from before messageFontFamily existed. + final settings = AppSettings.fromJson({ + 'monospaceMessages': true, + }); + expect(settings.messageFontFamily, 'monospace'); + }); + + test( + 'settings repository saves and loads the message font family', + () async { + final repository = SharedPrefsSettingsRepository(); + + await repository.saveSettings( + const AppSettings(messageFontFamily: 'serif'), + ); + final settings = await repository.loadSettings(); + + expect(settings.messageFontFamily, 'serif'); + expect(settings.monospaceMessages, isFalse); + }, + ); + test('chat session persistence saves tabs and history', () async { final persistence = ChatSessionPersistence(); const tab = ChatTab( diff --git a/test/widget_test.dart b/test/widget_test.dart index 9dfb7f3..653d039 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -730,11 +730,13 @@ void main() { await tester.tap(find.text('Compact').last); await tester.pumpAndSettle(); - await scrollTo('settings-monospace-messages'); + await scrollTo('settings-message-font-family'); await tester.tap( - find.byKey(const Key('settings-monospace-messages')).first, + find.byKey(const Key('settings-message-font-family')).first, ); await tester.pumpAndSettle(); + await tester.tap(find.text('Monospace').last); + await tester.pumpAndSettle(); await scrollTo('settings-nick-color-mode'); await tester.tap(find.byKey(const Key('settings-nick-color-mode')).first); @@ -746,6 +748,7 @@ void main() { expect(settings.themePreset, AppThemePreset.custom); expect(settings.customThemeJson, customJson); expect(settings.messageDensity, MessageDensity.compact); + expect(settings.messageFontFamily, 'monospace'); expect(settings.monospaceMessages, isTrue); expect(settings.nickColorMode, NickColorMode.vivid); }); From 8ddd425bc33f0cea46b79592cc80446840b6b463 Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 28 Aug 2026 10:08:47 +0200 Subject: [PATCH 05/11] Add message format editor with timestamp and nick style options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New Settings → Appearance → Message format screen with live preview - Timestamp format (24h/12h, with/without seconds) and position (before/after nick) settings applied to the chat message list - Nick decoration styles: plain, , nick:, [nick] --- lib/core/models/app_settings.dart | 38 ++++ .../chat/presentation/chat_screen.dart | 40 ++-- .../presentation/message_line_format.dart | 55 +++++ .../presentation/message_format_screen.dart | 204 ++++++++++++++++++ .../presentation/settings_screen.dart | 22 ++ test/message_format_test.dart | 110 ++++++++++ 6 files changed, 455 insertions(+), 14 deletions(-) create mode 100644 lib/features/chat/presentation/message_line_format.dart create mode 100644 lib/features/settings/presentation/message_format_screen.dart create mode 100644 test/message_format_test.dart diff --git a/lib/core/models/app_settings.dart b/lib/core/models/app_settings.dart index adf0bb6..cde1918 100644 --- a/lib/core/models/app_settings.dart +++ b/lib/core/models/app_settings.dart @@ -1,5 +1,11 @@ enum NoticeRoutingMode { server, active, notice, private } +/// How sender nicks are decorated in the message list. +enum NickDisplayFormat { plain, angle, colon, bracket } + +/// Where the timestamp sits relative to the sender nick. +enum TimestampPosition { afterNick, beforeNick } + enum AppThemePreset { light, dark, ircap, custom } enum MessageDensity { compact, comfortable, relaxed } @@ -32,6 +38,9 @@ class AppSettings { this.notificationSound = true, this.hideJoinPartQuit = false, this.showTimestamps = true, + this.timestampFormat = 'HH:mm', + this.timestampPosition = TimestampPosition.afterNick, + this.nickDisplayFormat = NickDisplayFormat.plain, this.enterToSend = true, this.showSendButton = true, this.highlightWords = const [], @@ -87,6 +96,12 @@ class AppSettings { // Display / writing. final bool hideJoinPartQuit; final bool showTimestamps; + + /// Timestamp pattern for message lines ('HH:mm', 'HH:mm:ss', 'h:mm a', + /// 'h:mm:ss a'). + final String timestampFormat; + final TimestampPosition timestampPosition; + final NickDisplayFormat nickDisplayFormat; final bool enterToSend; final bool showSendButton; @@ -130,6 +145,9 @@ class AppSettings { bool? notificationSound, bool? hideJoinPartQuit, bool? showTimestamps, + String? timestampFormat, + TimestampPosition? timestampPosition, + NickDisplayFormat? nickDisplayFormat, bool? enterToSend, bool? showSendButton, List? highlightWords, @@ -172,6 +190,9 @@ class AppSettings { notificationSound: notificationSound ?? this.notificationSound, hideJoinPartQuit: hideJoinPartQuit ?? this.hideJoinPartQuit, showTimestamps: showTimestamps ?? this.showTimestamps, + timestampFormat: timestampFormat ?? this.timestampFormat, + timestampPosition: timestampPosition ?? this.timestampPosition, + nickDisplayFormat: nickDisplayFormat ?? this.nickDisplayFormat, enterToSend: enterToSend ?? this.enterToSend, showSendButton: showSendButton ?? this.showSendButton, highlightWords: highlightWords ?? this.highlightWords, @@ -211,6 +232,9 @@ class AppSettings { 'notificationSound': notificationSound, 'hideJoinPartQuit': hideJoinPartQuit, 'showTimestamps': showTimestamps, + 'timestampFormat': timestampFormat, + 'timestampPosition': timestampPosition.name, + 'nickDisplayFormat': nickDisplayFormat.name, 'enterToSend': enterToSend, 'showSendButton': showSendButton, 'highlightWords': highlightWords, @@ -275,6 +299,20 @@ class AppSettings { notificationSound: (json['notificationSound'] as bool?) ?? true, hideJoinPartQuit: (json['hideJoinPartQuit'] as bool?) ?? false, showTimestamps: (json['showTimestamps'] as bool?) ?? true, + timestampFormat: + (json['timestampFormat'] as String?)?.trim().isNotEmpty ?? false + ? (json['timestampFormat']! as String).trim() + : 'HH:mm', + timestampPosition: _enumByName( + TimestampPosition.values, + json['timestampPosition'], + TimestampPosition.afterNick, + ), + nickDisplayFormat: _enumByName( + NickDisplayFormat.values, + json['nickDisplayFormat'], + NickDisplayFormat.plain, + ), enterToSend: (json['enterToSend'] as bool?) ?? true, showSendButton: (json['showSendButton'] as bool?) ?? true, highlightWords: _stringList(json['highlightWords']), diff --git a/lib/features/chat/presentation/chat_screen.dart b/lib/features/chat/presentation/chat_screen.dart index 665682f..e786c6d 100644 --- a/lib/features/chat/presentation/chat_screen.dart +++ b/lib/features/chat/presentation/chat_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:androidircx/app/theme/app_theme.dart'; +import 'package:androidircx/core/models/app_settings.dart'; import 'package:androidircx/core/models/chat_tab.dart'; import 'package:androidircx/core/models/connection_state.dart'; import 'package:androidircx/core/models/dcc_session.dart'; @@ -18,6 +19,7 @@ import 'package:androidircx/features/connections/application/network_list_contro import 'package:androidircx/features/chat/presentation/channel_list_screen.dart'; import 'package:androidircx/features/chat/presentation/connection_details_screen.dart'; import 'package:androidircx/features/chat/presentation/media_player_screen.dart'; +import 'package:androidircx/features/chat/presentation/message_line_format.dart'; import 'package:androidircx/features/chat/presentation/ignore_list_screen.dart'; import 'package:androidircx/features/chat/presentation/irc_formatted_text.dart'; import 'package:androidircx/features/chat/presentation/join_channel_dialog.dart'; @@ -456,6 +458,11 @@ class _ChatScreenState extends State { ), ), showTimestamps: _controller.settings.showTimestamps, + timestampFormat: _controller.settings.timestampFormat, + timestampPosition: + _controller.settings.timestampPosition, + nickDisplayFormat: + _controller.settings.nickDisplayFormat, onLoadOlder: _controller.hasPersistentHistory && !_messageSearchVisible @@ -1595,13 +1602,6 @@ bool _isActiveDccTransfer(DccSession session) { session.status == DccSessionStatus.connected; } -String _formatClock(DateTime timestamp) { - final local = timestamp.toLocal(); - final hh = local.hour.toString().padLeft(2, '0'); - final mm = local.minute.toString().padLeft(2, '0'); - return '$hh:$mm'; -} - class _ComposerArea extends StatelessWidget { const _ComposerArea({ required this.suggestions, @@ -3019,6 +3019,9 @@ class _MessageList extends StatelessWidget { required this.onChannelTap, this.onLoadOlder, this.showTimestamps = true, + this.timestampFormat = 'HH:mm', + this.timestampPosition = TimestampPosition.afterNick, + this.nickDisplayFormat = NickDisplayFormat.plain, }); final List messages; @@ -3027,6 +3030,9 @@ class _MessageList extends StatelessWidget { final String nickPrefixes; final bool showAttachmentPreviews; final bool showTimestamps; + final String timestampFormat; + final TimestampPosition timestampPosition; + final NickDisplayFormat nickDisplayFormat; final Future Function()? onLoadOlder; final IrcMessage? Function(String replyId) resolveReplyTarget; final Map Function(IrcMessage message) resolveReactions; @@ -3119,7 +3125,15 @@ class _MessageList extends StatelessWidget { ircTheme.nickColorFor(message.sender) ?? Theme.of(context).colorScheme.onSurface, ); + final nickLabel = formatNickLabel(message.sender, nickDisplayFormat); + final clockText = formatIrcTimestamp( + message.timestamp, + timestampFormat, + ); final leadingSpans = [ + if (showTimestamps && + timestampPosition == TimestampPosition.beforeNick) + TextSpan(text: '$clockText ', style: metaStyle), if (_isInteractiveSender(message)) WidgetSpan( alignment: PlaceholderAlignment.baseline, @@ -3129,17 +3143,15 @@ class _MessageList extends StatelessWidget { onTap: () => onNickTap(message.sender), onLongPress: () => onNickLongPress(message.sender), child: RichText( - text: TextSpan(text: message.sender, style: senderStyle), + text: TextSpan(text: nickLabel, style: senderStyle), ), ), ) else - TextSpan(text: message.sender, style: senderStyle), - if (showTimestamps) - TextSpan( - text: ' ${_formatClock(message.timestamp)}', - style: metaStyle, - ), + TextSpan(text: nickLabel, style: senderStyle), + if (showTimestamps && + timestampPosition == TimestampPosition.afterNick) + TextSpan(text: ' $clockText', style: metaStyle), if (message.isPlayback) TextSpan(text: ' · history', style: metaStyle), const TextSpan(text: ' '), diff --git a/lib/features/chat/presentation/message_line_format.dart b/lib/features/chat/presentation/message_line_format.dart new file mode 100644 index 0000000..83eca6a --- /dev/null +++ b/lib/features/chat/presentation/message_line_format.dart @@ -0,0 +1,55 @@ +import 'package:androidircx/core/models/app_settings.dart'; + +/// Timestamp patterns offered in the message format editor. +const List<({String pattern, String label})> supportedTimestampFormats = [ + (pattern: 'HH:mm', label: '24-hour (13:05)'), + (pattern: 'HH:mm:ss', label: '24-hour with seconds (13:05:09)'), + (pattern: 'h:mm a', label: '12-hour (1:05 PM)'), + (pattern: 'h:mm:ss a', label: '12-hour with seconds (1:05:09 PM)'), +]; + +/// Formats [timestamp] (converted to local time) with one of the supported +/// patterns; unknown patterns fall back to 24-hour HH:mm. +String formatIrcTimestamp(DateTime timestamp, String pattern) { + final local = timestamp.toLocal(); + final hh24 = local.hour.toString().padLeft(2, '0'); + final mm = local.minute.toString().padLeft(2, '0'); + final ss = local.second.toString().padLeft(2, '0'); + final hour12 = switch (local.hour % 12) { + 0 => 12, + final hour => hour, + }; + final period = local.hour < 12 ? 'AM' : 'PM'; + return switch (pattern) { + 'HH:mm:ss' => '$hh24:$mm:$ss', + 'h:mm a' => '$hour12:$mm $period', + 'h:mm:ss a' => '$hour12:$mm:$ss $period', + _ => '$hh24:$mm', + }; +} + +/// Decorates a sender nick for display per the configured style. +String formatNickLabel(String nick, NickDisplayFormat format) { + return switch (format) { + NickDisplayFormat.plain => nick, + NickDisplayFormat.angle => '<$nick>', + NickDisplayFormat.colon => '$nick:', + NickDisplayFormat.bracket => '[$nick]', + }; +} + +String nickDisplayFormatLabel(NickDisplayFormat format) { + return switch (format) { + NickDisplayFormat.plain => 'nick', + NickDisplayFormat.angle => '', + NickDisplayFormat.colon => 'nick:', + NickDisplayFormat.bracket => '[nick]', + }; +} + +String timestampPositionLabel(TimestampPosition position) { + return switch (position) { + TimestampPosition.afterNick => 'After nick', + TimestampPosition.beforeNick => 'Before nick', + }; +} diff --git a/lib/features/settings/presentation/message_format_screen.dart b/lib/features/settings/presentation/message_format_screen.dart new file mode 100644 index 0000000..670e708 --- /dev/null +++ b/lib/features/settings/presentation/message_format_screen.dart @@ -0,0 +1,204 @@ +import 'package:androidircx/core/models/app_settings.dart'; +import 'package:androidircx/features/chat/presentation/message_line_format.dart'; +import 'package:flutter/material.dart'; + +/// Visual editor for how message lines are laid out: timestamp format and +/// position, plus sender nick decoration. Changes save immediately through +/// [onSettingsChanged] and the preview updates live. +class MessageFormatScreen extends StatefulWidget { + const MessageFormatScreen({ + super.key, + required this.initialSettings, + required this.onSettingsChanged, + }); + + final AppSettings initialSettings; + final ValueChanged onSettingsChanged; + + @override + State createState() => _MessageFormatScreenState(); +} + +class _MessageFormatScreenState extends State { + late AppSettings _settings; + + @override + void initState() { + super.initState(); + _settings = widget.initialSettings; + } + + void _update(AppSettings next) { + setState(() => _settings = next); + widget.onSettingsChanged(next); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar(title: const Text('Message format')), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text('Preview', style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + _MessageFormatPreview(settings: _settings), + const SizedBox(height: 16), + SwitchListTile( + key: const Key('message-format-show-timestamps'), + contentPadding: EdgeInsets.zero, + title: const Text('Show timestamps'), + value: _settings.showTimestamps, + onChanged: (value) => + _update(_settings.copyWith(showTimestamps: value)), + ), + if (_settings.showTimestamps) ...[ + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Timestamp format'), + trailing: DropdownButton( + key: const Key('message-format-timestamp-format'), + value: + supportedTimestampFormats.any( + (option) => option.pattern == _settings.timestampFormat, + ) + ? _settings.timestampFormat + : 'HH:mm', + onChanged: (value) { + if (value == null) { + return; + } + _update(_settings.copyWith(timestampFormat: value)); + }, + items: supportedTimestampFormats + .map( + (option) => DropdownMenuItem( + value: option.pattern, + child: Text(option.label), + ), + ) + .toList(growable: false), + ), + ), + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Timestamp position'), + trailing: DropdownButton( + key: const Key('message-format-timestamp-position'), + value: _settings.timestampPosition, + onChanged: (value) { + if (value == null) { + return; + } + _update(_settings.copyWith(timestampPosition: value)); + }, + items: TimestampPosition.values + .map( + (position) => DropdownMenuItem( + value: position, + child: Text(timestampPositionLabel(position)), + ), + ) + .toList(growable: false), + ), + ), + ], + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Nick style'), + subtitle: const Text('Decoration around sender names.'), + trailing: DropdownButton( + key: const Key('message-format-nick-style'), + value: _settings.nickDisplayFormat, + onChanged: (value) { + if (value == null) { + return; + } + _update(_settings.copyWith(nickDisplayFormat: value)); + }, + items: NickDisplayFormat.values + .map( + (format) => DropdownMenuItem( + value: format, + child: Text(nickDisplayFormatLabel(format)), + ), + ) + .toList(growable: false), + ), + ), + ], + ), + ), + ); + } +} + +class _MessageFormatPreview extends StatelessWidget { + const _MessageFormatPreview({required this.settings}); + + final AppSettings settings; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final sampleTime = DateTime(2026, 1, 1, 13, 5, 9); + final nickStyle = theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.primary, + ); + final metaStyle = theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ); + + List lineSpans(String nick, String message) { + final timeSpan = settings.showTimestamps + ? TextSpan( + text: formatIrcTimestamp(sampleTime, settings.timestampFormat), + style: metaStyle, + ) + : null; + final nickSpan = TextSpan( + text: formatNickLabel(nick, settings.nickDisplayFormat), + style: nickStyle, + ); + return [ + if (timeSpan != null && + settings.timestampPosition == TimestampPosition.beforeNick) ...[ + timeSpan, + const TextSpan(text: ' '), + ], + nickSpan, + if (timeSpan != null && + settings.timestampPosition == TimestampPosition.afterNick) ...[ + const TextSpan(text: ' '), + timeSpan, + ], + const TextSpan(text: ' '), + TextSpan(text: message, style: theme.textTheme.bodyMedium), + ]; + } + + return Container( + key: const Key('message-format-preview'), + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text.rich(TextSpan(children: lineSpans('alice', 'Hello there!'))), + const SizedBox(height: 6), + Text.rich( + TextSpan(children: lineSpans('bob', 'hi alice, welcome back')), + ), + ], + ), + ); + } +} diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index 00dd817..1641a92 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -14,6 +14,7 @@ import 'package:androidircx/features/monetization/presentation/purchase_screen.d import 'package:androidircx/features/onboarding/presentation/data_privacy_screen.dart'; import 'package:androidircx/features/settings/presentation/backup_screen.dart'; import 'package:androidircx/features/settings/presentation/crash_reports_screen.dart'; +import 'package:androidircx/features/settings/presentation/message_format_screen.dart'; import 'package:androidircx/features/settings/presentation/theme_editor_screen.dart'; import 'package:androidircx/monetization/monetization_config.dart'; import 'package:androidircx/monetization/monetization_controller.dart'; @@ -330,6 +331,16 @@ class _SettingsScreenState extends State { ), ), const Divider(height: 1), + ListTile( + key: const Key('settings-message-format'), + leading: const Icon(Icons.short_text), + title: const Text('Message format'), + subtitle: const Text( + 'Timestamp format/position and nick style.', + ), + onTap: _openMessageFormatEditor, + ), + const Divider(height: 1), ListTile( title: const Text('Nick colors'), subtitle: const Text( @@ -938,6 +949,17 @@ class _SettingsScreenState extends State { controller.selection = TextSelection.collapsed(offset: value.length); } + Future _openMessageFormatEditor() async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => MessageFormatScreen( + initialSettings: _settings, + onSettingsChanged: _saveSettings, + ), + ), + ); + } + Future _openThemeEditor() async { await Navigator.of(context).push( MaterialPageRoute( diff --git a/test/message_format_test.dart b/test/message_format_test.dart new file mode 100644 index 0000000..7676646 --- /dev/null +++ b/test/message_format_test.dart @@ -0,0 +1,110 @@ +import 'package:androidircx/core/models/app_settings.dart'; +import 'package:androidircx/features/chat/presentation/message_line_format.dart'; +import 'package:androidircx/features/settings/presentation/message_format_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('formatIrcTimestamp', () { + final afternoon = DateTime(2026, 1, 1, 13, 5, 9); + final midnight = DateTime(2026, 1, 1, 0, 5, 9); + final noon = DateTime(2026, 1, 1, 12, 5, 9); + + test('formats 24-hour patterns', () { + expect(formatIrcTimestamp(afternoon, 'HH:mm'), '13:05'); + expect(formatIrcTimestamp(afternoon, 'HH:mm:ss'), '13:05:09'); + }); + + test('formats 12-hour patterns with midnight/noon edges', () { + expect(formatIrcTimestamp(afternoon, 'h:mm a'), '1:05 PM'); + expect(formatIrcTimestamp(afternoon, 'h:mm:ss a'), '1:05:09 PM'); + expect(formatIrcTimestamp(midnight, 'h:mm a'), '12:05 AM'); + expect(formatIrcTimestamp(noon, 'h:mm a'), '12:05 PM'); + }); + + test('unknown pattern falls back to 24-hour HH:mm', () { + expect(formatIrcTimestamp(afternoon, 'bogus'), '13:05'); + }); + }); + + group('formatNickLabel', () { + test('applies each decoration style', () { + expect(formatNickLabel('alice', NickDisplayFormat.plain), 'alice'); + expect(formatNickLabel('alice', NickDisplayFormat.angle), ''); + expect(formatNickLabel('alice', NickDisplayFormat.colon), 'alice:'); + expect(formatNickLabel('alice', NickDisplayFormat.bracket), '[alice]'); + }); + }); + + group('AppSettings message format fields', () { + test('round-trip through JSON', () { + const settings = AppSettings( + timestampFormat: 'h:mm a', + timestampPosition: TimestampPosition.beforeNick, + nickDisplayFormat: NickDisplayFormat.angle, + ); + final restored = AppSettings.fromJson(settings.toJson()); + expect(restored.timestampFormat, 'h:mm a'); + expect(restored.timestampPosition, TimestampPosition.beforeNick); + expect(restored.nickDisplayFormat, NickDisplayFormat.angle); + }); + + test('missing JSON fields keep defaults', () { + final restored = AppSettings.fromJson(const {}); + expect(restored.timestampFormat, 'HH:mm'); + expect(restored.timestampPosition, TimestampPosition.afterNick); + expect(restored.nickDisplayFormat, NickDisplayFormat.plain); + }); + }); + + group('MessageFormatScreen', () { + testWidgets('edits timestamp and nick style with live preview', ( + tester, + ) async { + var settings = const AppSettings(); + await tester.pumpWidget( + MaterialApp( + home: MessageFormatScreen( + initialSettings: settings, + onSettingsChanged: (next) => settings = next, + ), + ), + ); + + // Preview renders the default nick style and 24-hour clock. + expect(find.text('Message format'), findsOneWidget); + expect(find.textContaining('alice', findRichText: true), findsWidgets); + + await tester.tap( + find.byKey(const Key('message-format-timestamp-format')), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('12-hour (1:05 PM)').last); + await tester.pumpAndSettle(); + expect(settings.timestampFormat, 'h:mm a'); + + await tester.tap( + find.byKey(const Key('message-format-timestamp-position')), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Before nick').last); + await tester.pumpAndSettle(); + expect(settings.timestampPosition, TimestampPosition.beforeNick); + + await tester.tap(find.byKey(const Key('message-format-nick-style'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('').last); + await tester.pumpAndSettle(); + expect(settings.nickDisplayFormat, NickDisplayFormat.angle); + + // Hiding timestamps removes the format/position controls. + await tester.tap(find.byKey(const Key('message-format-show-timestamps'))); + await tester.pumpAndSettle(); + expect(settings.showTimestamps, isFalse); + expect( + find.byKey(const Key('message-format-timestamp-format')), + findsNothing, + ); + }); + }); +} From 1a70900b269f7bed8a4cf49cf95506c16018eb8e Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 28 Aug 2026 10:18:46 +0200 Subject: [PATCH 06/11] Add per-event notification sounds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SoundService plays bundled WAV sounds (from the previous app) for IRC events: mention, PM, notice, join, kick, CTCP, connect/disconnect, send, error, and DCC offers, with master volume and per-event toggles - Settings → Notifications → Sounds screen with preview playback - Session controller triggers sounds independently of notification permission gating; connection sounds fire once per phase change - audioplayers backend, injectable player for tests --- assets/sounds/bip.wav | Bin 0 -> 1830 bytes assets/sounds/ctcp.wav | Bin 0 -> 3662 bytes assets/sounds/cuac.wav | Bin 0 -> 2072 bytes assets/sounds/deop.wav | Bin 0 -> 5066 bytes assets/sounds/disconnected.wav | Bin 0 -> 17598 bytes assets/sounds/fail.wav | Bin 0 -> 6766 bytes assets/sounds/join.wav | Bin 0 -> 8012 bytes assets/sounds/kick.wav | Bin 0 -> 4174 bytes assets/sounds/login.wav | Bin 0 -> 16776 bytes assets/sounds/notice.wav | Bin 0 -> 4074 bytes assets/sounds/op.wav | Bin 0 -> 4174 bytes assets/sounds/ring.wav | Bin 0 -> 9578 bytes assets/sounds/send.wav | Bin 0 -> 6734 bytes lib/app/app.dart | 58 +++-- lib/core/sound/audioplayers_sound_player.dart | 18 ++ lib/core/sound/sound_service.dart | 216 ++++++++++++++++++ .../presentation/bootstrap_screen.dart | 11 + .../application/chat_session_controller.dart | 42 ++++ .../presentation/settings_screen.dart | 15 ++ .../presentation/sound_settings_screen.dart | 106 +++++++++ linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 72 ++++++ pubspec.yaml | 7 +- test/sound_service_test.dart | 151 ++++++++++++ .../flutter/generated_plugin_registrant.cc | 3 + windows/flutter/generated_plugins.cmake | 1 + 28 files changed, 681 insertions(+), 26 deletions(-) create mode 100644 assets/sounds/bip.wav create mode 100644 assets/sounds/ctcp.wav create mode 100644 assets/sounds/cuac.wav create mode 100644 assets/sounds/deop.wav create mode 100644 assets/sounds/disconnected.wav create mode 100644 assets/sounds/fail.wav create mode 100644 assets/sounds/join.wav create mode 100644 assets/sounds/kick.wav create mode 100644 assets/sounds/login.wav create mode 100644 assets/sounds/notice.wav create mode 100644 assets/sounds/op.wav create mode 100644 assets/sounds/ring.wav create mode 100644 assets/sounds/send.wav create mode 100644 lib/core/sound/audioplayers_sound_player.dart create mode 100644 lib/core/sound/sound_service.dart create mode 100644 lib/features/settings/presentation/sound_settings_screen.dart create mode 100644 test/sound_service_test.dart diff --git a/assets/sounds/bip.wav b/assets/sounds/bip.wav new file mode 100644 index 0000000000000000000000000000000000000000..58cc8f1d04db7988280780634457ff78d940be95 GIT binary patch literal 1830 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 literal 0 HcmV?d00001 diff --git a/assets/sounds/cuac.wav b/assets/sounds/cuac.wav new file mode 100644 index 0000000000000000000000000000000000000000..780f5fef56a5052b973d5ddaa2e6474de8c11c71 GIT binary patch literal 2072 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 Q{rpvr^Vsu4ANJ(`0GeL38~^|S literal 0 HcmV?d00001 diff --git a/assets/sounds/disconnected.wav b/assets/sounds/disconnected.wav new file mode 100644 index 0000000000000000000000000000000000000000..1e411056a760c734126c2bbfa4b05560f7022983 GIT binary patch literal 17598 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 literal 0 HcmV?d00001 diff --git a/assets/sounds/fail.wav b/assets/sounds/fail.wav new file mode 100644 index 0000000000000000000000000000000000000000..951e4e6333c4b34347950c19c18db8950d7259be GIT binary patch literal 6766 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 RsyxuR!VaCMx(BqX?!QIg#N7Y@ literal 0 HcmV?d00001 diff --git a/assets/sounds/op.wav b/assets/sounds/op.wav new file mode 100644 index 0000000000000000000000000000000000000000..b610d55759f1666c3737c7a840acc158f4ff8354 GIT binary patch literal 4174 zcmeH}du&rx9LLK)Y9uO3qR~H$1SKZoc#aIl+y<=O7y;B2I!BaK1wd45t-^Nc3#jqvMKKjtZkI&*e-ad3@ ze0*&G21oJi=~Jgpo0+F?A3k~R!kHue^;ph})1P>1X0|-M_3#hpe)@W-(;`ft@$@q@ zmxx_GBgamkKC!1apvapuQCFpPx8>_``Kx ztZ;GK?1d{N*4Ax%j(jybvMFYka5LtoXA3m`?!mpIqkG@)2^qxP%#5YHa${)S!0rPF zb`SJKOj7=`tZbf0<%(|{9QpK

5p_Hf34iie-6xvC0J>6ZM$wX_sIoc2j2Lpa@jmPbB z*liZGiKa-Sfza!;8jV_|!f~Y%!xRdI{Cc2Ifnk_Zsl;)WN~Kn7G+M1rr`HpN!C)jw zilS+g*=(^`Z8p2z;cz-#Zg+LH$5T^N>-GA4et#fPR~HP0!r@4yzP_QMu`wELYHDtd z#p3anmX_Am*0#2`L?Y4N-kwY*I}n`=Yi|HMqLUfj0k0*K@OnGUl7PwDU{091B_5B* zVzFkRsi`R%jW#wmHZ;@&kw_#Q4u?XaV6YAd1Ok4)-{2v^gzy??W3&IST5Hte?kSs<90|UXLpFlSm?M<0H8tI97;098#KLpp2f?Wh!55TOA zSteW)z$_i@0WJ(cyF&K{z&;UZ%PHUhCSbeJ0Ol;vIlvLX=9xeuAdu8598<`uq?M94 z#AT&IK@q>OfS0#CH#;kHamJ#B3+B(8J0}e>XI}b}oV=AqrR9}!TuZrvEj?Ry?msqm z;g8GzT+g59jt*^%+Ho;IKc6pFdpZV>p8q>F=+gPocLFNGirke__nQZQxrDke{W#pL z=4ZVopu0!MuR#0sj=GAR#XL>&(65)TTs+lhE6T{0L`Kg3b#d$ipC~h{JoM4n?`QY5 z%5yV?wS(XOeBwQ~U@2F#_OtK4-0Bu)@pN5#zSx!2aC27IZrQi1o8spPZ0m=2bQlVk zi=68RH%H_=UR7Q1yGfHUPe>txsN9DDxzeK4sCWHO^SvqbTC9u{Sn4 z<;6uZdpKl~77H;)&__xHBHZbB>%@X`t+UpKmz7i!PB$eNiK>iFJ0TT`G0JYyRIZk& zXd7g{SgN5dMody(r8SxKa*0H)r)izMq7u{7lvZ9TRT31bRYQp;c@2Mv4ZZ(HS6)NJ6J0NZJHK zPZ%hZ*+d!$14)}LCWrweWkT@)DL|VcxXd&~n~*73Ax6v=h%=iNG6n?r4@8dD1_6hp z&E|lR0|`+G-hm{1<8njxq09rpWTFcMo0g2dAcuz(Wo!neY|3v?0)z1|&ch@#vLFPc zHe*Iio+DQRTS8gS3eczlCGrDg2r0!hp$H<_1|ug5FcEMO1X2u>WU7ZX8@2&@qRDDx zM93bmwXRB3U;AQmFQX^D#Lkf#%l;5E=Y+gP!ES}wOG@6TLI=Yi_@% literal 0 HcmV?d00001 diff --git a/assets/sounds/ring.wav b/assets/sounds/ring.wav new file mode 100644 index 0000000000000000000000000000000000000000..add3f58bfb9e43924361b4afea4cad72899f3017 GIT binary patch literal 9578 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 K01WUylz#(#!wez- literal 0 HcmV?d00001 diff --git a/lib/app/app.dart b/lib/app/app.dart index dbdeca8..4a97b03 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -6,6 +6,8 @@ import 'package:androidircx/core/platform/foreground_connection_service.dart'; import 'package:androidircx/core/platform/screen_security.dart'; import 'package:androidircx/core/security/secret_storage.dart'; import 'package:androidircx/core/settings/app_settings_controller.dart'; +import 'package:androidircx/core/sound/audioplayers_sound_player.dart'; +import 'package:androidircx/core/sound/sound_service.dart'; import 'package:androidircx/core/storage/network_repository.dart'; import 'package:androidircx/core/storage/settings_repository.dart'; import 'package:androidircx/core/storage/shared_prefs_network_repository.dart'; @@ -31,6 +33,7 @@ class AndroidIrcxApp extends StatefulWidget { this.monetizationController, this.rewardedAdService, this.purchaseService, + this.soundService, }); final NetworkRepository? networkRepository; @@ -41,6 +44,9 @@ class AndroidIrcxApp extends StatefulWidget { final RewardedAdService? rewardedAdService; final StorePurchaseService? purchaseService; + /// Overridable for tests; defaults to the audioplayers-backed service. + final SoundService? soundService; + @override State createState() => _AndroidIrcxAppState(); } @@ -50,6 +56,7 @@ class _AndroidIrcxAppState extends State { late final MonetizationController _monetizationController; late final RewardedAdService _rewardedAdService; late final StorePurchaseService _purchaseService; + late final SoundService _soundService; late final bool _ownsMonetizationController; late final bool _ownsRewardedAdService; late final bool _ownsPurchaseService; @@ -73,6 +80,9 @@ class _AndroidIrcxAppState extends State { _purchaseService = widget.purchaseService ?? StorePurchaseService(monetizationController: _monetizationController); + _soundService = + widget.soundService ?? SoundService(player: AudioplayersSoundPlayer()); + unawaited(_soundService.load()); _settingsController.addListener(_applySettingsSideEffects); _settingsController.load(); unawaited(_monetizationController.initialize()); @@ -122,29 +132,32 @@ class _AndroidIrcxAppState extends State { controller: _monetizationController, rewardedAdService: _rewardedAdService, purchaseService: _purchaseService, - child: AppSettingsScope( - controller: _settingsController, - child: AnimatedBuilder( - animation: _settingsController, - builder: (context, _) { - return MaterialApp( - title: 'AndroidIRCx Flutter', - debugShowCheckedModeBanner: false, - theme: buildAppTheme(_settingsController.settings), - builder: (context, child) => AppLockGate( - enabled: - !_settingsController.isLoading && - _settingsController.settings.appLockEnabled, - child: MonetizationBanner( - controller: _monetizationController, - onboardingCompleted: - _settingsController.settings.onboardingCompleted, - child: child ?? const SizedBox.shrink(), + child: SoundScope( + service: _soundService, + child: AppSettingsScope( + controller: _settingsController, + child: AnimatedBuilder( + animation: _settingsController, + builder: (context, _) { + return MaterialApp( + title: 'AndroidIRCx Flutter', + debugShowCheckedModeBanner: false, + theme: buildAppTheme(_settingsController.settings), + builder: (context, child) => AppLockGate( + enabled: + !_settingsController.isLoading && + _settingsController.settings.appLockEnabled, + child: MonetizationBanner( + controller: _monetizationController, + onboardingCompleted: + _settingsController.settings.onboardingCompleted, + child: child ?? const SizedBox.shrink(), + ), ), - ), - home: _buildHome(), - ); - }, + home: _buildHome(), + ); + }, + ), ), ), ); @@ -171,6 +184,7 @@ class _AndroidIrcxAppState extends State { networkRepository: widget.networkRepository, foregroundConnectionService: widget.foregroundConnectionService, historyRepositoryLoader: widget.historyRepositoryLoader, + soundService: _soundService, ); } } diff --git a/lib/core/sound/audioplayers_sound_player.dart b/lib/core/sound/audioplayers_sound_player.dart new file mode 100644 index 0000000..a95b5e1 --- /dev/null +++ b/lib/core/sound/audioplayers_sound_player.dart @@ -0,0 +1,18 @@ +import 'package:androidircx/core/sound/sound_service.dart'; +import 'package:audioplayers/audioplayers.dart'; + +/// Production [SoundPlayer] backed by the `audioplayers` plugin. A single +/// player instance is reused; a new event sound cuts off the previous one. +class AudioplayersSoundPlayer implements SoundPlayer { + AudioplayersSoundPlayer() { + _player.setReleaseMode(ReleaseMode.stop); + } + + final AudioPlayer _player = AudioPlayer(playerId: 'event-sounds'); + + @override + Future play(String assetPath, double volume) async { + await _player.stop(); + await _player.play(AssetSource(assetPath), volume: volume); + } +} diff --git a/lib/core/sound/sound_service.dart b/lib/core/sound/sound_service.dart new file mode 100644 index 0000000..8ec3284 --- /dev/null +++ b/lib/core/sound/sound_service.dart @@ -0,0 +1,216 @@ +import 'dart:convert'; + +import 'package:flutter/widgets.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Event types that can trigger a notification sound. Mirrors the previous +/// app's sound events, minus ones with no trigger in this client yet. +enum SoundEvent { + mention, + privateMessage, + notice, + join, + kick, + ctcp, + disconnect, + login, + send, + fail, + ring, +} + +const Map soundEventLabels = { + SoundEvent.mention: 'Mention / highlight', + SoundEvent.privateMessage: 'Private message', + SoundEvent.notice: 'Notice', + SoundEvent.join: 'User join', + SoundEvent.kick: 'Kicked from channel', + SoundEvent.ctcp: 'CTCP request', + SoundEvent.disconnect: 'Disconnected', + SoundEvent.login: 'Connected', + SoundEvent.send: 'Message sent', + SoundEvent.fail: 'Error', + SoundEvent.ring: 'DCC offer', +}; + +/// Asset filename for each event (bundled under assets/sounds/). +const Map soundEventAssets = { + SoundEvent.mention: 'cuac.wav', + SoundEvent.privateMessage: 'bip.wav', + SoundEvent.notice: 'notice.wav', + SoundEvent.join: 'join.wav', + SoundEvent.kick: 'kick.wav', + SoundEvent.ctcp: 'ctcp.wav', + SoundEvent.disconnect: 'disconnected.wav', + SoundEvent.login: 'login.wav', + SoundEvent.send: 'send.wav', + SoundEvent.fail: 'fail.wav', + SoundEvent.ring: 'ring.wav', +}; + +/// Events that make noise out of the box; the rest stay opt-in. +const Set _defaultEnabledEvents = { + SoundEvent.mention, + SoundEvent.privateMessage, + SoundEvent.disconnect, + SoundEvent.login, + SoundEvent.ring, +}; + +class SoundSettings { + const SoundSettings({ + this.enabled = true, + this.masterVolume = 0.7, + this.eventEnabled = const {}, + }); + + /// Global sound switch. + final bool enabled; + + /// 0.0–1.0 volume applied to every event sound. + final double masterVolume; + + /// Per-event overrides; events absent from the map use their default. + final Map eventEnabled; + + bool isEventEnabled(SoundEvent event) { + return eventEnabled[event] ?? _defaultEnabledEvents.contains(event); + } + + SoundSettings copyWith({ + bool? enabled, + double? masterVolume, + Map? eventEnabled, + }) { + return SoundSettings( + enabled: enabled ?? this.enabled, + masterVolume: (masterVolume ?? this.masterVolume).clamp(0.0, 1.0), + eventEnabled: eventEnabled ?? this.eventEnabled, + ); + } + + SoundSettings withEvent(SoundEvent event, bool value) { + return copyWith(eventEnabled: {...eventEnabled, event: value}); + } + + Map toJson() { + return { + 'enabled': enabled, + 'masterVolume': masterVolume, + 'events': { + for (final entry in eventEnabled.entries) entry.key.name: entry.value, + }, + }; + } + + factory SoundSettings.fromJson(Map json) { + final rawEvents = json['events']; + final events = {}; + if (rawEvents is Map) { + rawEvents.forEach((key, value) { + if (key is! String || value is! bool) { + return; + } + for (final event in SoundEvent.values) { + if (event.name == key) { + events[event] = value; + } + } + }); + } + return SoundSettings( + enabled: (json['enabled'] as bool?) ?? true, + masterVolume: ((json['masterVolume'] as num?)?.toDouble() ?? 0.7).clamp( + 0.0, + 1.0, + ), + eventEnabled: events, + ); + } +} + +/// Playback backend; the production implementation uses `audioplayers`, +/// tests inject a fake. +abstract class SoundPlayer { + Future play(String assetPath, double volume); +} + +/// Plays short event sounds per user settings; persists settings in +/// shared preferences. +class SoundService extends ChangeNotifier { + SoundService({required SoundPlayer player, this.storageKey = 'soundSettings'}) + : _player = player; + + final SoundPlayer _player; + final String storageKey; + + SoundSettings _settings = const SoundSettings(); + bool _loaded = false; + + SoundSettings get settings => _settings; + + Future load() async { + if (_loaded) { + return; + } + _loaded = true; + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(storageKey); + if (raw == null || raw.isEmpty) { + return; + } + final decoded = jsonDecode(raw); + if (decoded is Map) { + _settings = SoundSettings.fromJson(decoded); + notifyListeners(); + } + } catch (_) { + // Corrupt settings fall back to defaults. + } + } + + Future updateSettings(SoundSettings next) async { + _settings = next; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(storageKey, jsonEncode(next.toJson())); + } + + /// Plays the sound for [event] if sounds and the event are enabled. + /// Never throws: sound failures must not break message handling. + Future playEvent(SoundEvent event) async { + if (!_settings.enabled || + _settings.masterVolume <= 0 || + !_settings.isEventEnabled(event)) { + return; + } + await _playAsset(soundEventAssets[event]!); + } + + /// Plays [event]'s sound unconditionally (settings preview). + Future previewEvent(SoundEvent event) { + return _playAsset(soundEventAssets[event]!); + } + + Future _playAsset(String fileName) async { + try { + await _player.play('sounds/$fileName', _settings.masterVolume); + } catch (_) { + // Missing/undecodable asset or platform audio failure: stay silent. + } + } +} + +/// Shares one [SoundService] between chat sessions and the settings UI. +class SoundScope extends InheritedNotifier { + const SoundScope({ + super.key, + required SoundService service, + required super.child, + }) : super(notifier: service); + + static SoundService? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType()?.notifier; + } +} diff --git a/lib/features/bootstrap/presentation/bootstrap_screen.dart b/lib/features/bootstrap/presentation/bootstrap_screen.dart index 9834d3b..2e1a54c 100644 --- a/lib/features/bootstrap/presentation/bootstrap_screen.dart +++ b/lib/features/bootstrap/presentation/bootstrap_screen.dart @@ -5,6 +5,8 @@ import 'package:androidircx/core/review/review_prompt_service.dart'; import 'package:androidircx/core/security/history_encryption_key_manager.dart'; import 'package:androidircx/core/security/local_auth_history_unlock.dart'; import 'package:androidircx/core/security/secret_storage.dart'; +import 'package:androidircx/core/sound/audioplayers_sound_player.dart'; +import 'package:androidircx/core/sound/sound_service.dart'; import 'package:androidircx/core/storage/network_repository.dart'; import 'package:androidircx/core/storage/shared_prefs_network_repository.dart'; import 'package:androidircx/features/chat/application/chat_session_controller.dart'; @@ -34,11 +36,15 @@ class BootstrapScreen extends StatefulWidget { this.foregroundConnectionService = const MethodChannelForegroundConnectionService(), this.historyRepositoryLoader, + this.soundService, }); final NetworkRepository? networkRepository; final ForegroundConnectionService foregroundConnectionService; + /// Overridable for tests; defaults to the audioplayers-backed service. + final SoundService? soundService; + /// Overridable for tests; defaults to the biometric/PIN-gated encrypted /// history repository. When it returns null (e.g. auth declined), sessions /// keep messages in memory only. @@ -54,6 +60,7 @@ class _BootstrapScreenState extends State late final NetworkListController _controller; late final SessionRegistry _sessionRegistry; final UserListsRepository _userListsRepository = UserListsRepository(); + late final SoundService _soundService; MessageHistoryRepository? _historyRepository; bool _bootstrapComplete = false; @@ -62,12 +69,16 @@ class _BootstrapScreenState extends State super.initState(); WidgetsBinding.instance.addObserver(this); _foregroundConnectionService = widget.foregroundConnectionService; + _soundService = + widget.soundService ?? SoundService(player: AudioplayersSoundPlayer()); + unawaited(_soundService.load()); _sessionRegistry = SessionRegistry( foregroundService: _foregroundConnectionService, sessionFactory: (network) => ChatSessionController( network: network, historyRepository: _historyRepository, userListsRepository: _userListsRepository, + soundService: _soundService, ), ); _controller = NetworkListController( diff --git a/lib/features/chat/application/chat_session_controller.dart b/lib/features/chat/application/chat_session_controller.dart index 6e78292..3c66940 100644 --- a/lib/features/chat/application/chat_session_controller.dart +++ b/lib/features/chat/application/chat_session_controller.dart @@ -23,6 +23,7 @@ import 'package:androidircx/features/chat/presentation/join_channel_dialog.dart' import 'package:androidircx/core/models/channel_list_entry.dart'; import 'package:androidircx/core/security/certificate_store.dart'; import 'package:androidircx/core/security/secret_storage.dart'; +import 'package:androidircx/core/sound/sound_service.dart'; import 'package:androidircx/irc/models/irc_message_frame.dart'; import 'package:androidircx/irc/parser/ctcp.dart'; import 'package:androidircx/irc/parser/dcc_parser.dart'; @@ -199,6 +200,7 @@ class ChatSessionController extends ChangeNotifier { SettingsRepository? settingsRepository, CommandService? commandService, UserListsRepository? userListsRepository, + SoundService? soundService, int maxReconnectAttempts = 6, Duration reconnectBaseDelay = const Duration(seconds: 2), Duration reconnectMaxDelay = const Duration(seconds: 60), @@ -216,6 +218,7 @@ class ChatSessionController extends ChangeNotifier { settingsRepository ?? SharedPrefsSettingsRepository(), _commandService = commandService ?? CommandService(), _userListsRepository = userListsRepository, + _soundService = soundService, _maxReconnectAttempts = maxReconnectAttempts, _reconnectBaseDelay = reconnectBaseDelay, _reconnectMaxDelay = reconnectMaxDelay, @@ -241,6 +244,11 @@ class ChatSessionController extends ChangeNotifier { final SettingsRepository _settingsRepository; final CommandService _commandService; final UserListsRepository? _userListsRepository; + final SoundService? _soundService; + + /// Last connection phase a sound was played for, so repeated snapshots in + /// the same phase (or reconnect retries) do not re-trigger sounds. + ConnectionPhase? _lastSoundedPhase; List _userListEntries = const []; bool _userListEntriesLoaded = false; final int _maxReconnectAttempts; @@ -1205,6 +1213,7 @@ class ChatSessionController extends ChangeNotifier { text: text, replyTo: normalizedReply.isEmpty ? null : normalizedReply, ); + _playSound(SoundEvent.send); if (!_ircService.enabledCapabilities.contains('echo-message')) { _appendMessage( tabId: activeTab.id, @@ -2041,6 +2050,10 @@ class ChatSessionController extends ChangeNotifier { } if (snapshot.phase == ConnectionPhase.connected) { + if (_lastSoundedPhase != ConnectionPhase.connected) { + _lastSoundedPhase = ConnectionPhase.connected; + _playSound(SoundEvent.login); + } _autoHistoryRequestedChannels.clear(); _reconnectAttempt = 0; _pendingReconnectDelay = null; @@ -2055,6 +2068,12 @@ class ChatSessionController extends ChangeNotifier { if (snapshot.phase == ConnectionPhase.error || snapshot.phase == ConnectionPhase.disconnected) { + // Only beep on the drop from an established connection, not on every + // failed reconnect attempt. + if (_lastSoundedPhase == ConnectionPhase.connected) { + _lastSoundedPhase = snapshot.phase; + _playSound(SoundEvent.disconnect); + } _autoHistoryRequestedChannels.clear(); _autoJoinAttempted = false; _serviceAuthFallbackAttempted = false; @@ -2062,6 +2081,15 @@ class ChatSessionController extends ChangeNotifier { } } + /// Fire-and-forget event sound; failures never affect message handling. + void _playSound(SoundEvent event) { + final service = _soundService; + if (service == null) { + return; + } + unawaited(service.playEvent(event)); + } + Future _runPostRegistrationActions() async { await _sendServiceAuthFallbackIfNeeded(); await _autoJoinConfiguredChannels(); @@ -4674,6 +4702,7 @@ class ChatSessionController extends ChangeNotifier { if (nick == (_ircService.currentNick ?? network.nickname)) { _activeTabId = tab.id; } else { + _playSound(SoundEvent.join); _maybeApplyAutoModes( channel, nick, @@ -4720,6 +4749,9 @@ class ChatSessionController extends ChangeNotifier { '$kickedNick was kicked from $channel by ${frame.senderNick ?? '*'}${frame.trailing == null ? '' : ' (${frame.trailing})'}', kind: IrcMessageKind.system, ); + if (_isSelfNick(kickedNick)) { + _playSound(SoundEvent.kick); + } if (_isSelfNick(kickedNick) && _settings.autoRejoinOnKick && _connection.phase == ConnectionPhase.connected) { @@ -5744,6 +5776,7 @@ class ChatSessionController extends ChangeNotifier { kind: IrcMessageKind.system, ); _markActivityIfInactive(tabId); + _playSound(SoundEvent.ctcp); unawaited(_respondToCtcpRequest(senderNick, command, ctcp.args)); } @@ -6401,6 +6434,14 @@ class ChatSessionController extends ChangeNotifier { return; } + // Sounds are independent of the notification permission gating below. + _playSound(switch (channelKind) { + ForegroundNotificationChannelKind.highlights => SoundEvent.mention, + ForegroundNotificationChannelKind.dccTransfers => SoundEvent.ring, + _ when tab.type == ChatTabType.notice => SoundEvent.notice, + _ => SoundEvent.privateMessage, + }); + _emitNotification( channelKind: channelKind, tabId: message.tabId, @@ -6420,6 +6461,7 @@ class ChatSessionController extends ChangeNotifier { if (normalizedBody.isEmpty) { return; } + _playSound(SoundEvent.fail); _emitNotification( channelKind: ForegroundNotificationChannelKind.errors, tabId: tabId, diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index 1641a92..6d1795a 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -15,6 +15,7 @@ import 'package:androidircx/features/onboarding/presentation/data_privacy_screen import 'package:androidircx/features/settings/presentation/backup_screen.dart'; import 'package:androidircx/features/settings/presentation/crash_reports_screen.dart'; import 'package:androidircx/features/settings/presentation/message_format_screen.dart'; +import 'package:androidircx/features/settings/presentation/sound_settings_screen.dart'; import 'package:androidircx/features/settings/presentation/theme_editor_screen.dart'; import 'package:androidircx/monetization/monetization_config.dart'; import 'package:androidircx/monetization/monetization_controller.dart'; @@ -523,6 +524,20 @@ class _SettingsScreenState extends State { _SettingsSection( title: 'Notifications', children: [ + ListTile( + key: const Key('settings-sound-settings'), + leading: const Icon(Icons.music_note_outlined), + title: const Text('Sounds'), + subtitle: const Text( + 'Per-event sounds, volume, and preview.', + ), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const SoundSettingsScreen(), + ), + ), + ), + const Divider(height: 1), SwitchListTile( key: const Key('settings-notifications-enabled'), secondary: const Icon( diff --git a/lib/features/settings/presentation/sound_settings_screen.dart b/lib/features/settings/presentation/sound_settings_screen.dart new file mode 100644 index 0000000..2b1409e --- /dev/null +++ b/lib/features/settings/presentation/sound_settings_screen.dart @@ -0,0 +1,106 @@ +import 'package:androidircx/core/sound/sound_service.dart'; +import 'package:flutter/material.dart'; + +/// Per-event notification sound settings with preview playback. +class SoundSettingsScreen extends StatefulWidget { + const SoundSettingsScreen({super.key, this.service}); + + /// Overridable for tests; defaults to the app-wide [SoundScope] service. + final SoundService? service; + + @override + State createState() => _SoundSettingsScreenState(); +} + +class _SoundSettingsScreenState extends State { + SoundService? _service; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final resolved = widget.service ?? SoundScope.maybeOf(context); + if (identical(resolved, _service)) { + return; + } + _service?.removeListener(_onServiceChanged); + _service = resolved; + _service?.addListener(_onServiceChanged); + } + + @override + void dispose() { + _service?.removeListener(_onServiceChanged); + super.dispose(); + } + + void _onServiceChanged() { + if (mounted) { + setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + final service = _service; + if (service == null) { + return Scaffold( + appBar: AppBar(title: const Text('Sounds')), + body: const Center(child: Text('Sound service unavailable.')), + ); + } + final settings = service.settings; + return Scaffold( + appBar: AppBar(title: const Text('Sounds')), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + SwitchListTile( + key: const Key('sound-settings-enabled'), + contentPadding: EdgeInsets.zero, + title: const Text('Event sounds'), + subtitle: const Text('Play short sounds for IRC events.'), + value: settings.enabled, + onChanged: (value) => + service.updateSettings(settings.copyWith(enabled: value)), + ), + if (settings.enabled) ...[ + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + 'Volume', + style: Theme.of(context).textTheme.titleSmall, + ), + ), + Slider( + key: const Key('sound-settings-volume'), + value: settings.masterVolume, + divisions: 10, + label: '${(settings.masterVolume * 100).round()}%', + onChanged: (value) => service.updateSettings( + settings.copyWith(masterVolume: value), + ), + ), + const Divider(), + for (final event in SoundEvent.values) + SwitchListTile( + key: Key('sound-settings-event-${event.name}'), + contentPadding: EdgeInsets.zero, + title: Text(soundEventLabels[event] ?? event.name), + value: settings.isEventEnabled(event), + onChanged: (value) => + service.updateSettings(settings.withEvent(event, value)), + secondary: IconButton( + key: Key('sound-settings-preview-${event.name}'), + tooltip: 'Play sound', + icon: const Icon(Icons.play_circle_outline), + onPressed: () => service.previewEvent(event), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 3ccd551..2cffa44 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,11 +6,15 @@ #include "generated_plugin_registrant.h" +#include #include #include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); + audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index fbedf4a..a9cc2e1 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_linux file_selector_linux flutter_secure_storage_linux url_launcher_linux diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index a6d1c4d..0c18a5f 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,7 @@ import FlutterMacOS import Foundation +import audioplayers_darwin import file_selector_macos import firebase_analytics import firebase_app_check @@ -20,6 +21,7 @@ import video_player_avfoundation import webview_flutter_wkwebview func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FirebaseAnalyticsPlugin")) FirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FirebaseAppCheckPlugin")) diff --git a/pubspec.lock b/pubspec.lock index a1fca45..1b153ff 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: "2ba4bb2944baacbdd5372ff8254a8e7feb8c10d7739545e392f5605a8f618745" + url: "https://pub.dev" + source: hosted + version: "6.8.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: f5ff5b15620fbab8cb0849e9636c48e2b96c3f0f71723bbbe2ad3c761b205f05 + url: "https://pub.dev" + source: hosted + version: "5.3.0" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: "1ca553add991384ecf421b9569da850f3ab2472ffb83f6970b0416365abc51be" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: "15178b726b7cdee5364d0463c8d445630c4e0fb7d26612b73c767e7d25de9417" + url: "https://pub.dev" + source: hosted + version: "4.3.0" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "765f6f0e6dca55cb471c9483fc77700564b3484d19198aca4ebb5147c6c85acb" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: ae1e0103c865a03e273f6d13d97b93f5595eac09915729cd5e37ef96e2857319 + url: "https://pub.dev" + source: hosted + version: "5.3.0" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: a70ae82bba2dfcb6eb03dd4815d737a2d46d33ea5a96a03f535cfcaac490e413 + url: "https://pub.dev" + source: hosted + version: "4.4.1" boolean_selector: dependency: transitive description: @@ -1141,6 +1197,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "3a7b5d17422dd0f8d5c6c14feaa5a1c65638b9455f871a96f08437562c046931" + url: "https://pub.dev" + source: hosted + version: "3.4.1+2" term_glyph: dependency: transitive description: @@ -1229,6 +1293,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index bc209a7..fd605e3 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,6 +57,7 @@ dependencies: google_mobile_ads: ^9.1.0 in_app_purchase: ^3.3.0 enough_convert: ^1.6.0 + audioplayers: ^6.8.1 dev_dependencies: flutter_test: @@ -82,10 +83,8 @@ flutter: # the material Icons class. uses-material-design: true - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + assets: + - assets/sounds/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/test/sound_service_test.dart b/test/sound_service_test.dart new file mode 100644 index 0000000..56d30cd --- /dev/null +++ b/test/sound_service_test.dart @@ -0,0 +1,151 @@ +import 'package:androidircx/core/sound/sound_service.dart'; +import 'package:androidircx/features/settings/presentation/sound_settings_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _RecordingPlayer implements SoundPlayer { + final List<({String asset, double volume})> played = []; + + @override + Future play(String assetPath, double volume) async { + played.add((asset: assetPath, volume: volume)); + } +} + +class _ThrowingPlayer implements SoundPlayer { + @override + Future play(String assetPath, double volume) async { + throw StateError('audio backend unavailable'); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + group('SoundSettings', () { + test('defaults enable the common events only', () { + const settings = SoundSettings(); + expect(settings.isEventEnabled(SoundEvent.mention), isTrue); + expect(settings.isEventEnabled(SoundEvent.privateMessage), isTrue); + expect(settings.isEventEnabled(SoundEvent.login), isTrue); + expect(settings.isEventEnabled(SoundEvent.disconnect), isTrue); + expect(settings.isEventEnabled(SoundEvent.ring), isTrue); + expect(settings.isEventEnabled(SoundEvent.join), isFalse); + expect(settings.isEventEnabled(SoundEvent.send), isFalse); + }); + + test('round-trips through JSON including event overrides', () { + const settings = SoundSettings(enabled: false, masterVolume: 0.4); + final overridden = settings + .withEvent(SoundEvent.join, true) + .withEvent(SoundEvent.mention, false); + final restored = SoundSettings.fromJson(overridden.toJson()); + expect(restored.enabled, isFalse); + expect(restored.masterVolume, closeTo(0.4, 0.0001)); + expect(restored.isEventEnabled(SoundEvent.join), isTrue); + expect(restored.isEventEnabled(SoundEvent.mention), isFalse); + // Untouched events keep their defaults. + expect(restored.isEventEnabled(SoundEvent.privateMessage), isTrue); + }); + + test('every event has a bundled asset and a label', () { + for (final event in SoundEvent.values) { + expect(soundEventAssets[event], isNotNull, reason: event.name); + expect(soundEventLabels[event], isNotNull, reason: event.name); + } + }); + }); + + group('SoundService', () { + test('plays enabled events at master volume', () async { + final player = _RecordingPlayer(); + final service = SoundService(player: player); + + await service.playEvent(SoundEvent.mention); + + expect(player.played, hasLength(1)); + expect(player.played.single.asset, 'sounds/cuac.wav'); + expect(player.played.single.volume, closeTo(0.7, 0.0001)); + }); + + test('skips disabled events, muted volume, and global off', () async { + final player = _RecordingPlayer(); + final service = SoundService(player: player); + + // join is disabled by default. + await service.playEvent(SoundEvent.join); + expect(player.played, isEmpty); + + await service.updateSettings(service.settings.copyWith(masterVolume: 0)); + await service.playEvent(SoundEvent.mention); + expect(player.played, isEmpty); + + await service.updateSettings( + service.settings.copyWith(enabled: false, masterVolume: 1), + ); + await service.playEvent(SoundEvent.mention); + expect(player.played, isEmpty); + }); + + test('persists and reloads settings', () async { + final service = SoundService(player: _RecordingPlayer()); + await service.updateSettings( + service.settings + .copyWith(masterVolume: 0.3) + .withEvent(SoundEvent.send, true), + ); + + final reloaded = SoundService(player: _RecordingPlayer()); + await reloaded.load(); + expect(reloaded.settings.masterVolume, closeTo(0.3, 0.0001)); + expect(reloaded.settings.isEventEnabled(SoundEvent.send), isTrue); + }); + + test('player failures never propagate', () async { + final service = SoundService(player: _ThrowingPlayer()); + await service.playEvent(SoundEvent.mention); + await service.previewEvent(SoundEvent.mention); + }); + }); + + group('SoundSettingsScreen', () { + testWidgets('toggles events and previews sounds', (tester) async { + final player = _RecordingPlayer(); + final service = SoundService(player: player); + + await tester.pumpWidget( + MaterialApp(home: SoundSettingsScreen(service: service)), + ); + + await tester.tap(find.byKey(const Key('sound-settings-preview-mention'))); + await tester.pump(); + expect(player.played, hasLength(1)); + + // join defaults to off; enable it via the switch. + await tester.scrollUntilVisible( + find.byKey(const Key('sound-settings-event-join')), + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap(find.byKey(const Key('sound-settings-event-join'))); + await tester.pumpAndSettle(); + expect(service.settings.isEventEnabled(SoundEvent.join), isTrue); + + // Global switch hides the per-event list. + await tester.scrollUntilVisible( + find.byKey(const Key('sound-settings-enabled')), + -200, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap(find.byKey(const Key('sound-settings-enabled'))); + await tester.pumpAndSettle(); + expect(service.settings.enabled, isFalse); + expect(find.byKey(const Key('sound-settings-event-join')), findsNothing); + }); + }); +} diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 30d4ad0..f54e12f 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -15,6 +16,8 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { + AudioplayersWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); FirebaseAppCheckPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 030c229..a3e1dfc 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_windows file_selector_windows firebase_app_check firebase_core From 21b8aba9e2891c9a332e8897eb7068db3649a411 Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 28 Aug 2026 10:22:43 +0200 Subject: [PATCH 07/11] Add DCC transfers modal with app bar indicator - Chat app bar shows a transfers icon with an active-session badge whenever DCC sessions exist - Bottom sheet lists all sessions with live progress, speed/ETA, and per-session open-tab and cancel actions - closeDccSessionByTab lets the modal cancel any session, not just the active tab's --- .../application/chat_session_controller.dart | 10 +- .../chat/presentation/chat_screen.dart | 160 ++++++++++++++++++ test/widget_test.dart | 51 ++++++ 3 files changed, 219 insertions(+), 2 deletions(-) diff --git a/lib/features/chat/application/chat_session_controller.dart b/lib/features/chat/application/chat_session_controller.dart index 3c66940..4fec7c7 100644 --- a/lib/features/chat/application/chat_session_controller.dart +++ b/lib/features/chat/application/chat_session_controller.dart @@ -1314,8 +1314,14 @@ class ChatSessionController extends ChangeNotifier { await _startOutgoingDccSend(nick: normalizedNick, filePath: normalizedPath); } - Future closeActiveDccSession() async { - final session = activeDccSession; + Future closeActiveDccSession() { + return closeDccSessionByTab(activeTabId); + } + + /// Closes the DCC session bound to [tabId] (used by the transfers modal, + /// which can act on any session, not just the active tab's). + Future closeDccSessionByTab(String tabId) async { + final session = _dccSessions[tabId]; if (session == null) { return; } diff --git a/lib/features/chat/presentation/chat_screen.dart b/lib/features/chat/presentation/chat_screen.dart index e786c6d..9b390ea 100644 --- a/lib/features/chat/presentation/chat_screen.dart +++ b/lib/features/chat/presentation/chat_screen.dart @@ -225,6 +225,17 @@ class _ChatScreenState extends State { icon: const Icon(Icons.format_list_bulleted), tooltip: 'Channel list', ), + if (_controller.dccSessions.isNotEmpty) + IconButton( + key: const Key('chat-dcc-transfers'), + onPressed: _openDccTransfers, + icon: Badge.count( + count: _activeDccTransferCount, + isLabelVisible: _activeDccTransferCount > 0, + child: const Icon(Icons.swap_vert_circle_outlined), + ), + tooltip: 'DCC transfers', + ), IconButton( onPressed: _openSettings, icon: const Icon(Icons.tune), @@ -936,6 +947,27 @@ class _ChatScreenState extends State { ); } + /// Sessions still doing work (pending offers or live connections). + int get _activeDccTransferCount => _controller.dccSessions + .where( + (session) => switch (session.status) { + DccSessionStatus.pending || + DccSessionStatus.offering || + DccSessionStatus.connecting || + DccSessionStatus.connected => true, + DccSessionStatus.closed || DccSessionStatus.failed => false, + }, + ) + .length; + + Future _openDccTransfers() async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => _DccTransfersSheet(controller: _controller), + ); + } + void _toggleMessageSearch() { setState(() { if (_messageSearchVisible) { @@ -2081,6 +2113,134 @@ class _DccSessionBanner extends StatelessWidget { } } +/// Bottom sheet listing every DCC session of this network's controller with +/// live progress and per-session actions. +class _DccTransfersSheet extends StatelessWidget { + const _DccTransfersSheet({required this.controller}); + + final ChatSessionController controller; + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: controller, + builder: (context, _) { + final sessions = controller.dccSessions; + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'DCC transfers', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + if (sessions.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: Text('No DCC sessions.')), + ) + else + Flexible( + child: ListView.separated( + shrinkWrap: true, + itemCount: sessions.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) => _DccTransferRow( + session: sessions[index], + controller: controller, + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class _DccTransferRow extends StatelessWidget { + const _DccTransferRow({required this.session, required this.controller}); + + final DccSession session; + final ChatSessionController controller; + + @override + Widget build(BuildContext context) { + final isDone = + session.status == DccSessionStatus.closed || + session.status == DccSessionStatus.failed; + final size = session.size; + final progress = (size != null && size > 0) + ? (session.bytesTransferred / size).clamp(0.0, 1.0) + : null; + final title = switch (session.type) { + DccSessionType.chat => 'CHAT with ${session.peerNick}', + DccSessionType.send => + '${session.direction == 'incoming' ? '⬇' : '⬆'} ' + '${session.filename ?? 'file'} • ${session.peerNick}', + DccSessionType.unknown => 'DCC • ${session.peerNick}', + }; + final subtitle = session.type == DccSessionType.send + ? _dccTransferSubtitle(session) + : 'Status: ${session.status.name}' + '${session.error == null ? '' : ' • ${session.error}'}'; + return ListTile( + key: Key('dcc-transfer-row-${session.tabId}'), + contentPadding: EdgeInsets.zero, + leading: Icon(switch (session.status) { + DccSessionStatus.failed => Icons.error_outline, + DccSessionStatus.closed => Icons.check_circle_outline, + _ => + session.type == DccSessionType.chat + ? Icons.chat_outlined + : Icons.swap_vert, + }), + title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(subtitle, maxLines: 2, overflow: TextOverflow.ellipsis), + if (progress != null && !isDone) ...[ + const SizedBox(height: 4), + LinearProgressIndicator(value: progress), + ], + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + key: Key('dcc-transfer-open-${session.tabId}'), + tooltip: 'Open tab', + icon: const Icon(Icons.open_in_new), + onPressed: () { + controller.selectTab(session.tabId); + Navigator.of(context).pop(); + }, + ), + if (!isDone) + IconButton( + key: Key('dcc-transfer-close-${session.tabId}'), + tooltip: 'Cancel', + icon: const Icon(Icons.close), + onPressed: () => controller.closeDccSessionByTab(session.tabId), + ), + ], + ), + onTap: () { + controller.selectTab(session.tabId); + Navigator.of(context).pop(); + }, + ); + } +} + class _ReplyPreview extends StatelessWidget { const _ReplyPreview({required this.referenced, required this.replyId}); diff --git a/test/widget_test.dart b/test/widget_test.dart index 653d039..169bc79 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'package:androidircx/app/app.dart'; import 'package:androidircx/core/models/app_settings.dart'; +import 'package:androidircx/core/models/dcc_session.dart'; import 'package:androidircx/core/models/network_config.dart'; import 'package:androidircx/core/platform/foreground_connection_service.dart'; import 'package:androidircx/core/presets/server_preset_service.dart'; @@ -1569,6 +1570,56 @@ void main() { controller.dispose(); }); + testWidgets('lists dcc sessions in the transfers modal', (tester) async { + SharedPreferences.setMockInitialValues({}); + const network = NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.dbase.in.rs', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + ); + final transport = _FakeTransport(); + final controller = ChatSessionController( + network: network, + ircService: IrcService(transportConnector: (_) async => transport), + ); + + await tester.pumpWidget( + MaterialApp(home: ChatScreen(controller: controller)), + ); + await tester.pump(); + + // No sessions yet -> no transfers button in the app bar. + expect(find.byKey(const Key('chat-dcc-transfers')), findsNothing); + + transport.emit( + ':alice!user@example PRIVMSG AndroidIRCX :DCC SEND notes.txt 2130706433 5002 2048', + ); + await tester.pump(); + await tester.pump(); + + expect(find.byKey(const Key('chat-dcc-transfers')), findsOneWidget); + + await tester.tap(find.byKey(const Key('chat-dcc-transfers'))); + await tester.pumpAndSettle(); + + expect(find.text('DCC transfers'), findsOneWidget); + expect(find.textContaining('notes.txt'), findsWidgets); + + final session = controller.dccSessions.single; + await tester.tap(find.byKey(Key('dcc-transfer-close-${session.tabId}'))); + await tester.pumpAndSettle(); + + expect( + controller.dccSessions.single.status, + anyOf(DccSessionStatus.closed, DccSessionStatus.failed), + ); + + controller.dispose(); + }); + testWidgets('shows reverse dcc limitations for passive send offers', ( tester, ) async { From 7f0fd8c28e2d1aea0d5ff2a68566d7b703e37f7c Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 28 Aug 2026 10:28:00 +0200 Subject: [PATCH 08/11] Add sending message reactions from the message actions sheet - Quick-reaction emoji row on long-press for messages with a msgid - reactToMessage sends the draft/react TAGMSG (RN-compatible msgid;emoji tag format) and records the local reaction immediately --- .../application/chat_session_controller.dart | 26 ++++++++++ .../chat/presentation/chat_screen.dart | 52 +++++++++++++++++++ test/chat_session_controller_test.dart | 47 +++++++++++++++++ test/widget_test.dart | 20 ++++++- 4 files changed, 143 insertions(+), 2 deletions(-) diff --git a/lib/features/chat/application/chat_session_controller.dart b/lib/features/chat/application/chat_session_controller.dart index 4fec7c7..5e5b7cc 100644 --- a/lib/features/chat/application/chat_session_controller.dart +++ b/lib/features/chat/application/chat_session_controller.dart @@ -624,6 +624,32 @@ class ChatSessionController extends ChangeNotifier { return summary; } + /// Sends a reaction to [message] over TAGMSG and records it locally so the + /// chip shows immediately. Returns false when the message has no msgid or + /// its tab cannot receive reactions. + Future reactToMessage(IrcMessage message, String emoji) async { + final msgid = (message.tags['msgid'] ?? '').trim(); + final normalizedEmoji = emoji.trim(); + if (msgid.isEmpty || normalizedEmoji.isEmpty) { + return false; + } + final tab = _findTab(message.tabId); + if (tab == null || + (tab.type != ChatTabType.channel && tab.type != ChatTabType.query)) { + return false; + } + await _ircService.sendReaction( + target: tab.name, + msgid: msgid, + emoji: normalizedEmoji, + ); + // Record locally; the echoed TAGMSG re-recording the same nick+emoji is + // idempotent because reactions are per-emoji nick sets. + _recordReaction(msgid, normalizedEmoji, currentNick); + notifyListeners(); + return true; + } + String userDetailsForNick(String nick) { final info = userInfoForNick(nick); if (info.nick.trim().isEmpty) { diff --git a/lib/features/chat/presentation/chat_screen.dart b/lib/features/chat/presentation/chat_screen.dart index 9b390ea..988ca0b 100644 --- a/lib/features/chat/presentation/chat_screen.dart +++ b/lib/features/chat/presentation/chat_screen.dart @@ -442,6 +442,7 @@ class _ChatScreenState extends State { resolveReplyTarget: (replyId) => _controller .messageByMsgId(_controller.activeTabId, replyId), resolveReactions: _controller.reactionsForMessage, + onReactToMessage: _controller.reactToMessage, onRedactMessage: _controller.redactMessage, onQuoteMessage: (message) => _insertIntoComposer( '> ${stripIrcFormatting(message.content)}', @@ -3169,6 +3170,7 @@ class _MessageList extends StatelessWidget { required this.showAttachmentPreviews, required this.resolveReplyTarget, required this.resolveReactions, + required this.onReactToMessage, required this.onRedactMessage, required this.onQuoteMessage, required this.onReplyWithNick, @@ -3196,6 +3198,8 @@ class _MessageList extends StatelessWidget { final Future Function()? onLoadOlder; final IrcMessage? Function(String replyId) resolveReplyTarget; final Map Function(IrcMessage message) resolveReactions; + final Future Function(IrcMessage message, String emoji) + onReactToMessage; final Future Function(IrcMessage message) onRedactMessage; final ValueChanged onQuoteMessage; final ValueChanged onReplyWithNick; @@ -3393,6 +3397,8 @@ class _MessageList extends StatelessWidget { ) async { final urls = extractUrls(stripIrcFormatting(message.content)); final canRedact = (message.tags['msgid'] ?? '').trim().isNotEmpty; + final canReact = + (message.tags['msgid'] ?? '').trim().isNotEmpty && !message.isOwn; await showModalBottomSheet( context: context, builder: (context) { @@ -3404,6 +3410,52 @@ class _MessageList extends StatelessWidget { child: ListView( shrinkWrap: true, children: [ + if (canReact) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Wrap( + spacing: 4, + children: [ + for (final emoji in const [ + '👍', + '❤️', + '😂', + '😮', + '😢', + '🎉', + ]) + IconButton( + key: Key('message-react-$emoji'), + onPressed: () async { + final sent = await onReactToMessage( + message, + emoji, + ); + if (!context.mounted) { + return; + } + Navigator.of(context).pop(); + if (!sent) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'This message cannot be reacted to.', + ), + ), + ); + } + }, + icon: Text( + emoji, + style: const TextStyle(fontSize: 22), + ), + ), + ], + ), + ), ListTile( leading: const Icon(Icons.copy), title: const Text('Copy clean text'), diff --git a/test/chat_session_controller_test.dart b/test/chat_session_controller_test.dart index a1e539a..5a632c9 100644 --- a/test/chat_session_controller_test.dart +++ b/test/chat_session_controller_test.dart @@ -2063,6 +2063,53 @@ void main() { controller.dispose(); }); + test('reactToMessage sends TAGMSG and records the local reaction', () async { + final transport = _FakeTransport(); + final service = IrcService(transportConnector: (_) async => transport); + final controller = ChatSessionController( + network: const NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + ), + ircService: service, + ); + + await controller.start(); + await controller.joinChannel(const JoinChannelRequest(channel: '#room')); + transport.emit('@msgid=react-2 :alice!user@example PRIVMSG #room :Hi'); + await Future.delayed(Duration.zero); + + final message = controller + .messagesForTab( + controller.tabs.firstWhere((tab) => tab.name == '#room').id, + ) + .firstWhere((item) => item.tags['msgid'] == 'react-2'); + + final sent = await controller.reactToMessage(message, '👍'); + expect(sent, isTrue); + expect( + transport.sentLines, + contains('@+draft/react=react-2\\:👍 TAGMSG #room'), + ); + expect(controller.reactionsForMessage(message), containsPair('👍', 1)); + + // Messages without a msgid cannot be reacted to. + transport.emit(':alice!user@example PRIVMSG #room :No msgid here'); + await Future.delayed(Duration.zero); + final plain = controller + .messagesForTab( + controller.tabs.firstWhere((tab) => tab.name == '#room').id, + ) + .lastWhere((item) => item.content == 'No msgid here'); + expect(await controller.reactToMessage(plain, '👍'), isFalse); + + controller.dispose(); + }); + test('handles account away host and setname user-state frames', () async { final transport = _FakeTransport(); final service = IrcService(transportConnector: (_) async => transport); diff --git a/test/widget_test.dart b/test/widget_test.dart index 169bc79..0bbb809 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1474,12 +1474,28 @@ void main() { await tester.pump(); await tester.pump(); + // Reacting from the actions sheet sends a TAGMSG with the combined tag. await tester.longPress(find.textContaining('Delete this').first); await tester.pumpAndSettle(); - expect(find.text('Delete message'), findsOneWidget); + await tester.tap(find.byKey(const Key('message-react-👍'))); + await tester.pumpAndSettle(); + expect( + transport.sentLines, + contains('@+draft/react=seed-redact\\:👍 TAGMSG #room'), + ); - await tester.ensureVisible(find.text('Delete message')); + await tester.longPress(find.textContaining('Delete this').first); await tester.pumpAndSettle(); + + // The quick-reaction row sits above the actions and can push this item + // below the fold in the sheet's list. + await tester.scrollUntilVisible( + find.text('Delete message'), + 120, + scrollable: find.byType(Scrollable).last, + ); + expect(find.text('Delete message'), findsOneWidget); + await tester.tap(find.text('Delete message')); await tester.pump(); From 02a0af6d82848e58318a4935e2be7f423203378e Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 28 Aug 2026 11:08:03 +0200 Subject: [PATCH 09/11] Add channel settings, command aliases, offline queue, and moderation presets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ChannelSettingsScreen (tab options → Channel settings): topic, auto-join toggle persisted to the network config, local note editor, recent log - Command aliases editor in Settings → Writing: user aliases persist, expand in the composer, and can shadow built-in shortcuts - Offline outbox: messages typed while disconnected queue with a notice and flush in order after registration completes - Connection quality: app bar status shows lag and good/ok/slow rating - Kick/ban preset reasons are user-editable in Settings → Channels and feed the moderation dialog --- lib/core/backup/backup_service.dart | 6 +- .../diagnostics/crash_report_sanitizer.dart | 10 +- lib/core/firebase/firebase_service.dart | 5 +- lib/core/platform/screen_security.dart | 5 +- lib/core/presets/server_preset_service.dart | 13 +- lib/core/review/review_prompt_service.dart | 7 +- lib/core/security/certificate_store.dart | 25 +-- .../history_encryption_key_manager.dart | 6 +- .../storage/identity_profile_repository.dart | 18 +- .../storage/in_memory_network_repository.dart | 9 +- lib/dcc/services/dcc_socket_backend_stub.dart | 3 +- .../application/chat_session_controller.dart | 97 +++++++++ .../chat/application/command_service.dart | 131 ++++++++++-- .../chat/data/channel_notes_repository.dart | 7 +- lib/features/chat/data/history_database.dart | 116 ++++++----- .../chat/data/history_payload_cipher.dart | 2 +- .../data/kick_ban_reasons_repository.dart | 48 +++++ .../chat/data/user_notes_repository.dart | 4 +- .../presentation/channel_settings_screen.dart | 197 ++++++++++++++++++ .../chat/presentation/chat_screen.dart | 44 +++- .../presentation/join_channel_dialog.dart | 10 +- .../application/network_list_controller.dart | 37 ++++ lib/features/connections/data/pem_bundle.dart | 5 +- .../presentation/profiles_screen.dart | 4 +- .../presentation/onboarding_screen.dart | 13 +- .../settings/presentation/backup_screen.dart | 7 +- .../presentation/command_aliases_screen.dart | 182 ++++++++++++++++ .../presentation/crash_reports_screen.dart | 6 +- .../presentation/kick_ban_reasons_screen.dart | 150 +++++++++++++ .../presentation/settings_screen.dart | 30 +++ .../presentation/theme_editor_screen.dart | 5 +- lib/irc/parser/ctcp.dart | 9 +- lib/irc/parser/irc_url_parser.dart | 19 +- lib/irc/parser/mirc_preset_parser.dart | 11 +- .../services/irc_transport_connector_web.dart | 7 +- test/attachment_tap_action_test.dart | 6 +- test/auto_mode_test.dart | 9 +- test/backup_service_test.dart | 9 +- test/certificate_store_test.dart | 27 ++- test/channel_settings_test.dart | 108 ++++++++++ test/chat_screen_landscape_test.dart | 5 +- test/chat_session_controller_test.dart | 48 +++++ test/command_aliases_test.dart | 98 +++++++++ test/crash_reporter_test.dart | 34 +-- test/crash_reports_screen_test.dart | 4 +- test/ctcp_test.dart | 5 +- test/history_encryption_key_manager_test.dart | 54 ++--- test/history_payload_cipher_test.dart | 19 +- test/identity_profile_test.dart | 37 ++-- test/irc_transport_web_framing_test.dart | 56 ++--- test/kick_ban_reasons_test.dart | 54 +++++ test/mirc_preset_parser_test.dart | 4 +- test/network_form_cert_import_test.dart | 8 +- ...notification_permission_settings_test.dart | 33 +-- test/pem_bundle_test.dart | Bin 1553 -> 1549 bytes test/scram_sha256_session_test.dart | 5 +- test/server_preset_test.dart | 25 ++- 57 files changed, 1544 insertions(+), 352 deletions(-) create mode 100644 lib/features/chat/data/kick_ban_reasons_repository.dart create mode 100644 lib/features/chat/presentation/channel_settings_screen.dart create mode 100644 lib/features/settings/presentation/command_aliases_screen.dart create mode 100644 lib/features/settings/presentation/kick_ban_reasons_screen.dart create mode 100644 test/channel_settings_test.dart create mode 100644 test/command_aliases_test.dart create mode 100644 test/kick_ban_reasons_test.dart diff --git a/lib/core/backup/backup_service.dart b/lib/core/backup/backup_service.dart index 3fe4753..dd2c6c5 100644 --- a/lib/core/backup/backup_service.dart +++ b/lib/core/backup/backup_service.dart @@ -30,9 +30,9 @@ class BackupService { required NetworkRepository networkRepository, required SettingsRepository settingsRepository, required IdentityProfileRepository profileRepository, - }) : _networks = networkRepository, - _settings = settingsRepository, - _profiles = profileRepository; + }) : _networks = networkRepository, + _settings = settingsRepository, + _profiles = profileRepository; static const int backupVersion = 1; diff --git a/lib/core/diagnostics/crash_report_sanitizer.dart b/lib/core/diagnostics/crash_report_sanitizer.dart index 40e9717..496fa6b 100644 --- a/lib/core/diagnostics/crash_report_sanitizer.dart +++ b/lib/core/diagnostics/crash_report_sanitizer.dart @@ -35,14 +35,8 @@ class CrashReportSanitizer { } var out = input; out = out.replaceAll(_pemBlock, redaction); - out = out.replaceAllMapped( - _authCommand, - (m) => '${m.group(1)} $redaction', - ); - out = out.replaceAllMapped( - _keyValue, - (m) => '${m.group(1)}=$redaction', - ); + out = out.replaceAllMapped(_authCommand, (m) => '${m.group(1)} $redaction'); + out = out.replaceAllMapped(_keyValue, (m) => '${m.group(1)}=$redaction'); out = out.replaceAll(_longToken, redaction); return out; } diff --git a/lib/core/firebase/firebase_service.dart b/lib/core/firebase/firebase_service.dart index 98b496b..f194342 100644 --- a/lib/core/firebase/firebase_service.dart +++ b/lib/core/firebase/firebase_service.dart @@ -32,8 +32,9 @@ class FirebaseService { } await Firebase.initializeApp(); await FirebaseAppCheck.instance.activate( - providerAndroid: - kReleaseMode ? AndroidPlayIntegrityProvider() : AndroidDebugProvider(), + providerAndroid: kReleaseMode + ? AndroidPlayIntegrityProvider() + : AndroidDebugProvider(), ); _initialized = true; diff --git a/lib/core/platform/screen_security.dart b/lib/core/platform/screen_security.dart index 87b55d2..0893754 100644 --- a/lib/core/platform/screen_security.dart +++ b/lib/core/platform/screen_security.dart @@ -4,8 +4,9 @@ import 'package:flutter/services.dart'; class ScreenSecurity { const ScreenSecurity(); - static const MethodChannel _channel = - MethodChannel('androidircx/screen_security'); + static const MethodChannel _channel = MethodChannel( + 'androidircx/screen_security', + ); Future setSecure(bool secure) async { try { diff --git a/lib/core/presets/server_preset_service.dart b/lib/core/presets/server_preset_service.dart index cac0e3a..a1ef258 100644 --- a/lib/core/presets/server_preset_service.dart +++ b/lib/core/presets/server_preset_service.dart @@ -12,23 +12,20 @@ typedef PresetHttpGet = Future Function(Uri url); /// `DEFAULT_SERVER`). class ServerPresetService { ServerPresetService({PresetHttpGet? httpGet}) - : _httpGet = httpGet ?? _defaultHttpGet; + : _httpGet = httpGet ?? _defaultHttpGet; final PresetHttpGet _httpGet; - static final Uri endpoint = - Uri.parse('https://irc.dbase.in.rs/api/irc/server-presets'); + static final Uri endpoint = Uri.parse( + 'https://irc.dbase.in.rs/api/irc/server-presets', + ); /// The old app's default network, used when the directory cannot be reached. static const ServerPreset fallbackPreset = ServerPreset( networkName: 'DBase', averageUsers: 0, servers: [ - ServerPresetServer( - hostname: 'irc.dbase.in.rs', - port: 6697, - useSsl: true, - ), + ServerPresetServer(hostname: 'irc.dbase.in.rs', port: 6697, useSsl: true), ], ); diff --git a/lib/core/review/review_prompt_service.dart b/lib/core/review/review_prompt_service.dart index 88fbc6a..1ae468c 100644 --- a/lib/core/review/review_prompt_service.dart +++ b/lib/core/review/review_prompt_service.dart @@ -7,10 +7,9 @@ class ReviewPromptService { ReviewPromptService({ Future Function()? isAvailable, Future Function()? requestReview, - }) : _isAvailable = - isAvailable ?? (() => InAppReview.instance.isAvailable()), - _requestReview = - requestReview ?? (() => InAppReview.instance.requestReview()); + }) : _isAvailable = isAvailable ?? (() => InAppReview.instance.isAvailable()), + _requestReview = + requestReview ?? (() => InAppReview.instance.requestReview()); static const String _launchKey = 'androidircx.launchCount'; static const String _promptedKey = 'androidircx.reviewPrompted'; diff --git a/lib/core/security/certificate_store.dart b/lib/core/security/certificate_store.dart index 4c336b3..d113cdb 100644 --- a/lib/core/security/certificate_store.dart +++ b/lib/core/security/certificate_store.dart @@ -79,8 +79,9 @@ class CertificateStore { final passphrase = await _storage.getSecret( _key(networkId, NetworkSecretField.clientKeyPassphrase), ); - final normalizedPassphrase = - (passphrase == null || passphrase.isEmpty) ? null : passphrase; + final normalizedPassphrase = (passphrase == null || passphrase.isEmpty) + ? null + : passphrase; final pkcs12 = await _storage.getSecret( _key(networkId, NetworkSecretField.clientPkcs12), @@ -164,24 +165,18 @@ void validateClientCertificate(ClientCertificate certificate) { } return; } - if (!_isPemBlock( - certificate.certificatePem, - const ['CERTIFICATE'], - )) { + if (!_isPemBlock(certificate.certificatePem, const ['CERTIFICATE'])) { throw const CertificateFormatException( 'Client certificate must be a PEM block ' '(-----BEGIN CERTIFICATE----- … -----END CERTIFICATE-----).', ); } - if (!_isPemBlock( - certificate.privateKeyPem, - const [ - 'PRIVATE KEY', - 'RSA PRIVATE KEY', - 'EC PRIVATE KEY', - 'ENCRYPTED PRIVATE KEY', - ], - )) { + if (!_isPemBlock(certificate.privateKeyPem, const [ + 'PRIVATE KEY', + 'RSA PRIVATE KEY', + 'EC PRIVATE KEY', + 'ENCRYPTED PRIVATE KEY', + ])) { throw const CertificateFormatException( 'Client private key must be a PEM private-key block.', ); diff --git a/lib/core/security/history_encryption_key_manager.dart b/lib/core/security/history_encryption_key_manager.dart index e09e6bd..7d6114b 100644 --- a/lib/core/security/history_encryption_key_manager.dart +++ b/lib/core/security/history_encryption_key_manager.dart @@ -35,9 +35,9 @@ class HistoryEncryptionKeyManager { required SecretStorage storage, required HistoryUnlockAuthenticator authenticator, List Function()? keyBytesGenerator, - }) : _storage = storage, - _authenticator = authenticator, - _keyBytesGenerator = keyBytesGenerator ?? _defaultKeyBytes; + }) : _storage = storage, + _authenticator = authenticator, + _keyBytesGenerator = keyBytesGenerator ?? _defaultKeyBytes; static const String storageKey = 'androidircx.history.databaseKey'; static const int keyLengthBytes = 32; // 256-bit diff --git a/lib/core/storage/identity_profile_repository.dart b/lib/core/storage/identity_profile_repository.dart index 2786d8b..c483044 100644 --- a/lib/core/storage/identity_profile_repository.dart +++ b/lib/core/storage/identity_profile_repository.dart @@ -16,12 +16,14 @@ List normalizeProfiles(List profiles) { final others = profiles.where( (profile) => profile.id != IdentityProfile.defaultProfileId, ); - return List.unmodifiable( - [IdentityProfile.defaultProfile, ...others], - ); + return List.unmodifiable([ + IdentityProfile.defaultProfile, + ...others, + ]); } -class SharedPrefsIdentityProfileRepository implements IdentityProfileRepository { +class SharedPrefsIdentityProfileRepository + implements IdentityProfileRepository { static const _storageKey = 'androidircx.identityProfiles'; @override @@ -85,10 +87,10 @@ class SharedPrefsIdentityProfileRepository implements IdentityProfileRepository class InMemoryIdentityProfileRepository implements IdentityProfileRepository { InMemoryIdentityProfileRepository([List initial = const []]) - : _profiles = [ - for (final profile in initial) - if (profile.id != IdentityProfile.defaultProfileId) profile, - ]; + : _profiles = [ + for (final profile in initial) + if (profile.id != IdentityProfile.defaultProfileId) profile, + ]; final List _profiles; diff --git a/lib/core/storage/in_memory_network_repository.dart b/lib/core/storage/in_memory_network_repository.dart index 90ae30d..a613043 100644 --- a/lib/core/storage/in_memory_network_repository.dart +++ b/lib/core/storage/in_memory_network_repository.dart @@ -2,9 +2,8 @@ import 'package:androidircx/core/models/network_config.dart'; import 'package:androidircx/core/storage/network_repository.dart'; class InMemoryNetworkRepository implements NetworkRepository { - InMemoryNetworkRepository([ - List? seed, - ]) : _networks = List.from(seed ?? _defaultSeed); + InMemoryNetworkRepository([List? seed]) + : _networks = List.from(seed ?? _defaultSeed); final List _networks; @@ -28,9 +27,7 @@ class InMemoryNetworkRepository implements NetworkRepository { @override Future> loadNetworks() async { - return List.unmodifiable( - _networks.map(_normalizeNetwork), - ); + return List.unmodifiable(_networks.map(_normalizeNetwork)); } @override diff --git a/lib/dcc/services/dcc_socket_backend_stub.dart b/lib/dcc/services/dcc_socket_backend_stub.dart index 226a9ee..8cb6c26 100644 --- a/lib/dcc/services/dcc_socket_backend_stub.dart +++ b/lib/dcc/services/dcc_socket_backend_stub.dart @@ -15,4 +15,5 @@ class _UnsupportedDccSocketBackend implements DccSocketBackend { } } -DccSocketBackend createPlatformDccSocketBackend() => _UnsupportedDccSocketBackend(); +DccSocketBackend createPlatformDccSocketBackend() => + _UnsupportedDccSocketBackend(); diff --git a/lib/features/chat/application/chat_session_controller.dart b/lib/features/chat/application/chat_session_controller.dart index 5e5b7cc..b08ebab 100644 --- a/lib/features/chat/application/chat_session_controller.dart +++ b/lib/features/chat/application/chat_session_controller.dart @@ -487,6 +487,9 @@ class ChatSessionController extends ChangeNotifier { int get activityCount => _tabs.where((tab) => tab.hasActivity).length; bool get hasActivity => activityCount > 0; String? get activeChannelTopic => _channelTopics[activeTabId]; + + /// Topic for any tab (used by the per-channel settings screen). + String? topicForTab(String tabId) => _channelTopics[tabId]; String? get activeChannelModes => _channelModes[activeTabId]; DateTime? get activeReadMarker => _readMarkers[activeTabId]; List get activeTypingUsers => List.unmodifiable( @@ -1234,6 +1237,24 @@ class ChatSessionController extends ChangeNotifier { } final normalizedReply = (replyTo ?? '').trim(); + // Queue only when clearly offline; while connecting/registering the + // transport is live and sends keep their previous behavior. + final isOffline = switch (_connection.phase) { + ConnectionPhase.idle || + ConnectionPhase.disconnected || + ConnectionPhase.error || + ConnectionPhase.reconnecting => true, + _ => false, + }; + if (isOffline) { + _queueOfflineMessage( + tabId: activeTab.id, + target: activeTab.name, + text: text, + replyTo: normalizedReply.isEmpty ? null : normalizedReply, + ); + return; + } await _ircService.sendPrivmsg( target: activeTab.name, text: text, @@ -2008,6 +2029,8 @@ class ChatSessionController extends ChangeNotifier { Future reloadSettings() async { _settings = await _settingsRepository.loadSettings(); + // Pick up alias edits made in Settings → Command aliases. + await _commandService.load(); _applySettingsToServices(); notifyListeners(); } @@ -2125,6 +2148,80 @@ class ChatSessionController extends ChangeNotifier { Future _runPostRegistrationActions() async { await _sendServiceAuthFallbackIfNeeded(); await _autoJoinConfiguredChannels(); + await _flushOfflineOutbox(); + } + + /// Messages typed while disconnected, sent in order after the next + /// successful registration (after auto-join, so channel sends follow the + /// JOIN on the wire). + final List<({String tabId, String target, String text, String? replyTo})> + _offlineOutbox = []; + static const int _maxOfflineOutbox = 50; + + int get queuedOfflineMessages => _offlineOutbox.length; + + void _queueOfflineMessage({ + required String tabId, + required String target, + required String text, + String? replyTo, + }) { + if (_offlineOutbox.length >= _maxOfflineOutbox) { + _appendMessage( + tabId: tabId, + sender: 'error', + content: 'Offline queue is full; message dropped.', + kind: IrcMessageKind.system, + ); + notifyListeners(); + return; + } + _offlineOutbox.add(( + tabId: tabId, + target: target, + text: text, + replyTo: replyTo, + )); + _appendMessage( + tabId: tabId, + sender: '*', + content: 'Not connected — message queued and will be sent on reconnect.', + kind: IrcMessageKind.system, + ); + notifyListeners(); + } + + Future _flushOfflineOutbox() async { + if (_offlineOutbox.isEmpty) { + return; + } + final pending = List.of(_offlineOutbox); + _offlineOutbox.clear(); + for (final item in pending) { + if (_connection.phase != ConnectionPhase.connected) { + // Connection dropped mid-flush: requeue the rest for the next cycle. + _offlineOutbox.add(item); + continue; + } + await _ircService.sendPrivmsg( + target: item.target, + text: item.text, + replyTo: item.replyTo, + ); + if (!_ircService.enabledCapabilities.contains('echo-message')) { + _appendMessage( + tabId: item.tabId, + sender: _ircService.currentNick ?? network.nickname, + content: item.text, + tags: { + if ((item.replyTo ?? '').isNotEmpty) 'draft/reply': item.replyTo!, + }, + isOwn: true, + ); + } + } + unawaited(_persistState()); + notifyListeners(); } Future _sendServiceAuthFallbackIfNeeded() async { diff --git a/lib/features/chat/application/command_service.dart b/lib/features/chat/application/command_service.dart index ade957c..abdb8bb 100644 --- a/lib/features/chat/application/command_service.dart +++ b/lib/features/chat/application/command_service.dart @@ -84,6 +84,7 @@ class CommandHistoryEntry { class CommandService { static const _historyKey = 'androidircx.commandHistory'; + static const _customAliasesKey = 'androidircx.commandAliases'; static const _maxHistory = 50; static const Map serviceAliases = { @@ -792,24 +793,33 @@ class CommandService { for (final command in _defaultCommands) command.name: command, }; + /// Built-in shortcuts; user-defined aliases can shadow but not delete them. + static const Map _defaultAliasCommands = { + 'j': '/join', + 'p': '/part', + 'q': '/quit', + 'w': '/whois', + 'n': '/nick', + 'm': '/msg', + 'a': '/me', + 'k': '/kick', + 'kb': '/kickban', + 'ns': '/nickserv', + 'cs': '/chanserv', + 'hs': '/hostserv', + 'os': '/operserv', + 'ms': '/memoserv', + 'bs': '/botserv', + }; + final Map _aliases = { - 'j': const CommandAlias(alias: 'j', command: '/join'), - 'p': const CommandAlias(alias: 'p', command: '/part'), - 'q': const CommandAlias(alias: 'q', command: '/quit'), - 'w': const CommandAlias(alias: 'w', command: '/whois'), - 'n': const CommandAlias(alias: 'n', command: '/nick'), - 'm': const CommandAlias(alias: 'm', command: '/msg'), - 'a': const CommandAlias(alias: 'a', command: '/me'), - 'k': const CommandAlias(alias: 'k', command: '/kick'), - 'kb': const CommandAlias(alias: 'kb', command: '/kickban'), - 'ns': const CommandAlias(alias: 'ns', command: '/nickserv'), - 'cs': const CommandAlias(alias: 'cs', command: '/chanserv'), - 'hs': const CommandAlias(alias: 'hs', command: '/hostserv'), - 'os': const CommandAlias(alias: 'os', command: '/operserv'), - 'ms': const CommandAlias(alias: 'ms', command: '/memoserv'), - 'bs': const CommandAlias(alias: 'bs', command: '/botserv'), + for (final entry in _defaultAliasCommands.entries) + entry.key: CommandAlias(alias: entry.key, command: entry.value), }; + /// Alias names the user defined (persisted separately from the defaults). + final Map _customAliases = {}; + List _history = const []; List get history => @@ -927,6 +937,7 @@ class CommandService { Future load() async { final prefs = await SharedPreferences.getInstance(); + _loadCustomAliases(prefs.getString(_customAliasesKey)); final raw = prefs.getString(_historyKey); if (raw == null || raw.isEmpty) { _history = const []; @@ -941,6 +952,96 @@ class CommandService { .toList(growable: false); } + void _loadCustomAliases(String? raw) { + if (raw == null || raw.isEmpty) { + return; + } + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) { + return; + } + decoded.forEach((key, value) { + if (key is! String || value is! String) { + return; + } + final alias = key.trim().toLowerCase(); + final command = value.trim(); + if (alias.isEmpty || !command.startsWith('/')) { + return; + } + _customAliases[alias] = command; + _aliases[alias] = CommandAlias(alias: alias, command: command); + }); + } catch (_) { + // Corrupt alias storage: keep the defaults. + } + } + + /// All active aliases (built-in and custom), sorted by name. + List get aliases { + final list = _aliases.values.toList() + ..sort((a, b) => a.alias.compareTo(b.alias)); + return List.unmodifiable(list); + } + + /// Whether [alias] currently comes from user configuration. + bool isCustomAlias(String alias) => + _customAliases.containsKey(alias.trim().toLowerCase()); + + /// Whether [alias] is one of the built-in defaults. + bool isBuiltInAlias(String alias) => + _defaultAliasCommands.containsKey(alias.trim().toLowerCase()); + + /// Adds or updates a user alias. Returns an error message on invalid input, + /// null on success. The alias may shadow a built-in shortcut but not an + /// actual command name. + Future setAlias(String alias, String command) async { + final normalizedAlias = alias.trim().toLowerCase().replaceFirst('/', ''); + var normalizedCommand = command.trim(); + if (normalizedAlias.isEmpty || + normalizedAlias.contains(RegExp(r'\s')) || + normalizedAlias.contains('/')) { + return 'Alias must be a single word without slashes.'; + } + if (isKnownCommand(normalizedAlias)) { + return '"/$normalizedAlias" is already a command.'; + } + if (!normalizedCommand.startsWith('/')) { + normalizedCommand = '/$normalizedCommand'; + } + if (normalizedCommand.length < 2) { + return 'Alias target must be a command, e.g. /join #channel.'; + } + _customAliases[normalizedAlias] = normalizedCommand; + _aliases[normalizedAlias] = CommandAlias( + alias: normalizedAlias, + command: normalizedCommand, + ); + await _persistCustomAliases(); + return null; + } + + /// Removes a user alias; built-in defaults reappear when unshadowed. + Future removeAlias(String alias) async { + final normalized = alias.trim().toLowerCase(); + if (_customAliases.remove(normalized) == null) { + return; + } + final builtIn = _defaultAliasCommands[normalized]; + if (builtIn != null) { + _aliases[normalized] = CommandAlias(alias: normalized, command: builtIn); + } else { + _aliases.remove(normalized); + } + await _persistCustomAliases(); + } + + Future _persistCustomAliases() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_customAliasesKey, jsonEncode(_customAliases)); + } + Future addToHistory(String command) async { final now = DateTime.now(); final entry = CommandHistoryEntry( diff --git a/lib/features/chat/data/channel_notes_repository.dart b/lib/features/chat/data/channel_notes_repository.dart index 4f56fd6..c5a0db2 100644 --- a/lib/features/chat/data/channel_notes_repository.dart +++ b/lib/features/chat/data/channel_notes_repository.dart @@ -13,8 +13,7 @@ class ChannelNotesRepository { final Future Function() _prefsLoader; - String _compositeKey(String network, String channel) => - '$network::$channel'; + String _compositeKey(String network, String channel) => '$network::$channel'; Future> _readAll(SharedPreferences prefs) async { final raw = prefs.getString(storageKey); @@ -24,9 +23,7 @@ class ChannelNotesRepository { try { final decoded = jsonDecode(raw); if (decoded is Map) { - return decoded.map( - (key, value) => MapEntry('$key', '${value ?? ''}'), - ); + return decoded.map((key, value) => MapEntry('$key', '${value ?? ''}')); } } catch (_) { // Corrupt blob: start fresh rather than throw. diff --git a/lib/features/chat/data/history_database.dart b/lib/features/chat/data/history_database.dart index 0ae6a1f..66ed6fe 100644 --- a/lib/features/chat/data/history_database.dart +++ b/lib/features/chat/data/history_database.dart @@ -76,20 +76,25 @@ class DriftMessageHistoryRepository implements MessageHistoryRepository { : message.copyWith(networkId: networkId); final dedupeId = _dedupeId(stored); if (dedupeId != null) { - final existing = await (_db.select(_db.messages) - ..where((row) => - row.networkId.equals(networkId) & - row.tabId.equals(stored.tabId) & - row.msgid.equals(dedupeId)) - ..limit(1)) - .get(); + final existing = + await (_db.select(_db.messages) + ..where( + (row) => + row.networkId.equals(networkId) & + row.tabId.equals(stored.tabId) & + row.msgid.equals(dedupeId), + ) + ..limit(1)) + .get(); if (existing.isNotEmpty) { return; } } final payload = await _codec.encrypt(jsonEncode(stored.toJson())); - await _db.into(_db.messages).insert( + await _db + .into(_db.messages) + .insert( MessagesCompanion.insert( networkId: networkId, tabId: stored.tabId, @@ -122,10 +127,12 @@ class DriftMessageHistoryRepository implements MessageHistoryRepository { final anchor = (beforeMessageId ?? '').trim(); int? beforeRowId; if (anchor.isNotEmpty) { - final rows = await (_db.select(_db.messages) - ..where((row) => - row.networkId.equals(networkId) & row.tabId.equals(tabId))) - .get(); + final rows = + await (_db.select(_db.messages)..where( + (row) => + row.networkId.equals(networkId) & row.tabId.equals(tabId), + )) + .get(); for (final row in rows) { final message = await _fromRow(row); if (message.id == anchor || message.tags['msgid'] == anchor) { @@ -137,8 +144,7 @@ class DriftMessageHistoryRepository implements MessageHistoryRepository { final query = _db.select(_db.messages) ..where((row) { - final base = - row.networkId.equals(networkId) & row.tabId.equals(tabId); + final base = row.networkId.equals(networkId) & row.tabId.equals(tabId); return beforeRowId == null ? base : base & row.rowId.isSmallerThanValue(beforeRowId); @@ -165,8 +171,10 @@ class DriftMessageHistoryRepository implements MessageHistoryRepository { int limit = 100, }) async { final normalizedLimit = limit.clamp(1, 10000); - final normalizedQuery = - formatIrcPlainText(query, collapseWhitespace: true).toLowerCase(); + final normalizedQuery = formatIrcPlainText( + query, + collapseWhitespace: true, + ).toLowerCase(); final select = _db.select(_db.messages) ..where((row) { @@ -175,16 +183,16 @@ class DriftMessageHistoryRepository implements MessageHistoryRepository { condition = condition & row.tabId.equals(tabId); } if (kinds.isNotEmpty) { - condition = - condition & row.kind.isIn(kinds.map((kind) => kind.name)); + condition = condition & row.kind.isIn(kinds.map((kind) => kind.name)); } if (from != null) { - condition = condition & - row.timestampMs - .isBiggerOrEqualValue(from.millisecondsSinceEpoch); + condition = + condition & + row.timestampMs.isBiggerOrEqualValue(from.millisecondsSinceEpoch); } if (to != null) { - condition = condition & + condition = + condition & row.timestampMs.isSmallerOrEqualValue(to.millisecondsSinceEpoch); } return condition; @@ -237,17 +245,17 @@ class DriftMessageHistoryRepository implements MessageHistoryRepository { DateTime? deleteBefore, }) async { if (deleteBefore != null) { - await (_db.delete(_db.messages) - ..where((row) { - var condition = row.networkId.equals(networkId) & - row.timestampMs.isSmallerThanValue( - deleteBefore.millisecondsSinceEpoch, - ); - if (tabId != null) { - condition = condition & row.tabId.equals(tabId); - } - return condition; - })) + await (_db.delete(_db.messages)..where((row) { + var condition = + row.networkId.equals(networkId) & + row.timestampMs.isSmallerThanValue( + deleteBefore.millisecondsSinceEpoch, + ); + if (tabId != null) { + condition = condition & row.tabId.equals(tabId); + } + return condition; + })) .go(); } @@ -256,29 +264,33 @@ class DriftMessageHistoryRepository implements MessageHistoryRepository { if (tabId != null) { tabIds.add(tabId); } else { - final rows = await (_db.selectOnly(_db.messages, distinct: true) - ..addColumns([_db.messages.tabId]) - ..where(_db.messages.networkId.equals(networkId))) - .get(); + final rows = + await (_db.selectOnly(_db.messages, distinct: true) + ..addColumns([_db.messages.tabId]) + ..where(_db.messages.networkId.equals(networkId))) + .get(); for (final row in rows) { tabIds.add(row.read(_db.messages.tabId)!); } } for (final id in tabIds) { - final rowIds = await (_db.select(_db.messages) - ..where((row) => - row.networkId.equals(networkId) & row.tabId.equals(id)) - ..orderBy([(row) => OrderingTerm.asc(row.rowId)])) - .map((row) => row.rowId) - .get(); + final rowIds = + await (_db.select(_db.messages) + ..where( + (row) => + row.networkId.equals(networkId) & row.tabId.equals(id), + ) + ..orderBy([(row) => OrderingTerm.asc(row.rowId)])) + .map((row) => row.rowId) + .get(); if (rowIds.length <= maxMessages) { continue; } final removable = rowIds.sublist(0, rowIds.length - maxMessages); - await (_db.delete(_db.messages) - ..where((row) => row.rowId.isIn(removable))) - .go(); + await (_db.delete( + _db.messages, + )..where((row) => row.rowId.isIn(removable))).go(); } } } @@ -288,17 +300,17 @@ class DriftMessageHistoryRepository implements MessageHistoryRepository { required String networkId, required String tabId, }) async { - await (_db.delete(_db.messages) - ..where((row) => - row.networkId.equals(networkId) & row.tabId.equals(tabId))) + await (_db.delete(_db.messages)..where( + (row) => row.networkId.equals(networkId) & row.tabId.equals(tabId), + )) .go(); } @override Future deleteNetworkHistory(String networkId) async { - await (_db.delete(_db.messages) - ..where((row) => row.networkId.equals(networkId))) - .go(); + await (_db.delete( + _db.messages, + )..where((row) => row.networkId.equals(networkId))).go(); } Future _fromRow(Message row) async { diff --git a/lib/features/chat/data/history_payload_cipher.dart b/lib/features/chat/data/history_payload_cipher.dart index 56cd717..a2ed512 100644 --- a/lib/features/chat/data/history_payload_cipher.dart +++ b/lib/features/chat/data/history_payload_cipher.dart @@ -10,7 +10,7 @@ import 'package:cryptography/cryptography.dart'; /// nonce, the ciphertext, and the GCM authentication tag, all base64-encoded. class AesGcmHistoryPayloadCodec implements HistoryPayloadCodec { AesGcmHistoryPayloadCodec(List keyBytes) - : _secretKey = SecretKey(keyBytes); + : _secretKey = SecretKey(keyBytes); factory AesGcmHistoryPayloadCodec.fromBase64Key(String base64Key) { return AesGcmHistoryPayloadCodec(base64Decode(base64Key)); diff --git a/lib/features/chat/data/kick_ban_reasons_repository.dart b/lib/features/chat/data/kick_ban_reasons_repository.dart new file mode 100644 index 0000000..46ba4a3 --- /dev/null +++ b/lib/features/chat/data/kick_ban_reasons_repository.dart @@ -0,0 +1,48 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +/// Preset kick/ban reasons offered in the moderation dialog; user-editable +/// in Settings → Channels. +class KickBanReasonsRepository { + static const _key = 'androidircx.kickBanReasons'; + + static const List defaultReasons = [ + 'Spam', + 'Flooding', + 'Abuse', + 'Off-topic', + 'Policy violation', + ]; + + Future> loadReasons() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_key); + if (raw == null || raw.isEmpty) { + return defaultReasons; + } + try { + final decoded = jsonDecode(raw); + if (decoded is! List) { + return defaultReasons; + } + final reasons = decoded + .whereType() + .map((reason) => reason.trim()) + .where((reason) => reason.isNotEmpty) + .toList(growable: false); + return reasons.isEmpty ? defaultReasons : reasons; + } catch (_) { + return defaultReasons; + } + } + + Future saveReasons(List reasons) async { + final prefs = await SharedPreferences.getInstance(); + final cleaned = reasons + .map((reason) => reason.trim()) + .where((reason) => reason.isNotEmpty) + .toList(growable: false); + await prefs.setString(_key, jsonEncode(cleaned)); + } +} diff --git a/lib/features/chat/data/user_notes_repository.dart b/lib/features/chat/data/user_notes_repository.dart index 9dc424e..261981d 100644 --- a/lib/features/chat/data/user_notes_repository.dart +++ b/lib/features/chat/data/user_notes_repository.dart @@ -24,9 +24,7 @@ class UserNotesRepository { try { final decoded = jsonDecode(raw); if (decoded is Map) { - return decoded.map( - (key, value) => MapEntry('$key', '${value ?? ''}'), - ); + return decoded.map((key, value) => MapEntry('$key', '${value ?? ''}')); } } catch (_) { // Corrupt blob: start fresh rather than throw. diff --git a/lib/features/chat/presentation/channel_settings_screen.dart b/lib/features/chat/presentation/channel_settings_screen.dart new file mode 100644 index 0000000..994e75c --- /dev/null +++ b/lib/features/chat/presentation/channel_settings_screen.dart @@ -0,0 +1,197 @@ +import 'dart:async'; + +import 'package:androidircx/core/models/chat_tab.dart'; +import 'package:androidircx/core/models/irc_message.dart'; +import 'package:androidircx/features/chat/application/chat_session_controller.dart'; +import 'package:androidircx/features/chat/data/channel_notes_repository.dart'; +import 'package:androidircx/features/connections/application/network_list_controller.dart'; +import 'package:androidircx/irc/parser/irc_formatter.dart'; +import 'package:flutter/material.dart'; + +/// Per-channel settings: topic overview, auto-join on connect, a local +/// channel note, and the recent message log — consolidated in one screen. +class ChannelSettingsScreen extends StatefulWidget { + const ChannelSettingsScreen({ + super.key, + required this.controller, + required this.tab, + this.networkController, + this.notesRepository, + }); + + final ChatSessionController controller; + final ChatTab tab; + + /// Needed to persist the auto-join toggle; hidden when absent. + final NetworkListController? networkController; + final ChannelNotesRepository? notesRepository; + + @override + State createState() => _ChannelSettingsScreenState(); +} + +class _ChannelSettingsScreenState extends State { + late final ChannelNotesRepository _notes; + final TextEditingController _noteController = TextEditingController(); + bool _autoJoin = false; + bool _noteLoaded = false; + + @override + void initState() { + super.initState(); + _notes = widget.notesRepository ?? ChannelNotesRepository(); + _autoJoin = widget.controller.network.autoJoinChannels.any( + (channel) => channel.toLowerCase() == widget.tab.name.toLowerCase(), + ); + unawaited(_loadNote()); + } + + @override + void dispose() { + _noteController.dispose(); + super.dispose(); + } + + Future _loadNote() async { + final note = await _notes.getNote( + widget.controller.network.id, + widget.tab.name, + ); + if (!mounted) { + return; + } + setState(() { + _noteController.text = note; + _noteLoaded = true; + }); + } + + Future _saveNote() async { + await _notes.setNote( + widget.controller.network.id, + widget.tab.name, + _noteController.text, + ); + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + _noteController.text.trim().isEmpty + ? 'Channel note cleared.' + : 'Channel note saved.', + ), + ), + ); + } + + Future _toggleAutoJoin(bool value) async { + final networkController = widget.networkController; + if (networkController == null) { + return; + } + setState(() => _autoJoin = value); + await networkController.setChannelAutoJoin( + networkId: widget.controller.network.id, + channel: widget.tab.name, + autoJoin: value, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final topic = widget.controller.topicForTab(widget.tab.id); + final messages = widget.controller.messagesForTab(widget.tab.id); + final recent = messages.length <= 50 + ? messages + : messages.sublist(messages.length - 50); + return Scaffold( + appBar: AppBar(title: Text(widget.tab.name)), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + if ((topic ?? '').trim().isNotEmpty) ...[ + Text('Topic', style: theme.textTheme.titleSmall), + const SizedBox(height: 4), + Text(stripIrcFormatting(topic!)), + const SizedBox(height: 16), + ], + if (widget.networkController != null) + SwitchListTile( + key: const Key('channel-settings-auto-join'), + contentPadding: EdgeInsets.zero, + title: const Text('Auto-join on connect'), + subtitle: const Text( + 'Join this channel automatically when the network connects.', + ), + value: _autoJoin, + onChanged: (value) => unawaited(_toggleAutoJoin(value)), + ), + const SizedBox(height: 8), + Text('Channel note', style: theme.textTheme.titleSmall), + const SizedBox(height: 4), + TextField( + key: const Key('channel-settings-note'), + controller: _noteController, + enabled: _noteLoaded, + minLines: 2, + maxLines: 5, + decoration: const InputDecoration( + hintText: 'Notes for this channel (stored only on this device)', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: FilledButton.tonal( + key: const Key('channel-settings-save-note'), + onPressed: _noteLoaded ? () => unawaited(_saveNote()) : null, + child: const Text('Save note'), + ), + ), + const SizedBox(height: 16), + Text( + 'Recent log (${recent.length})', + style: theme.textTheme.titleSmall, + ), + const SizedBox(height: 4), + if (recent.isEmpty) + const Text('No messages yet.') + else + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final message in recent) + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + _logLine(message), + style: theme.textTheme.bodySmall, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + String _logLine(IrcMessage message) { + final time = message.timestamp.toLocal(); + final hh = time.hour.toString().padLeft(2, '0'); + final mm = time.minute.toString().padLeft(2, '0'); + return '[$hh:$mm] ${message.sender}: ${stripIrcFormatting(message.content)}'; + } +} diff --git a/lib/features/chat/presentation/chat_screen.dart b/lib/features/chat/presentation/chat_screen.dart index 988ca0b..d7709ff 100644 --- a/lib/features/chat/presentation/chat_screen.dart +++ b/lib/features/chat/presentation/chat_screen.dart @@ -13,10 +13,12 @@ import 'package:androidircx/features/chat/application/command_service.dart'; import 'package:androidircx/features/chat/application/chat_session_controller.dart'; import 'package:androidircx/features/chat/application/session_registry.dart'; import 'package:androidircx/features/chat/data/channel_notes_repository.dart'; +import 'package:androidircx/features/chat/data/kick_ban_reasons_repository.dart'; import 'package:androidircx/features/chat/data/user_list_entry.dart'; import 'package:androidircx/features/chat/data/user_notes_repository.dart'; import 'package:androidircx/features/connections/application/network_list_controller.dart'; import 'package:androidircx/features/chat/presentation/channel_list_screen.dart'; +import 'package:androidircx/features/chat/presentation/channel_settings_screen.dart'; import 'package:androidircx/features/chat/presentation/connection_details_screen.dart'; import 'package:androidircx/features/chat/presentation/media_player_screen.dart'; import 'package:androidircx/features/chat/presentation/message_line_format.dart'; @@ -1009,7 +1011,16 @@ class _ChatScreenState extends State { case ConnectionPhase.authenticating: return snapshot.message ?? 'Authenticating'; case ConnectionPhase.connected: - return 'Connected'; + final lag = _controller.lag; + if (lag == null) { + return 'Connected'; + } + final quality = lag.inMilliseconds < 150 + ? 'good' + : lag.inMilliseconds < 500 + ? 'ok' + : 'slow'; + return 'Connected • ${lag.inMilliseconds} ms ($quality)'; case ConnectionPhase.reconnecting: return snapshot.message ?? 'Reconnecting'; case ConnectionPhase.disconnecting: @@ -1232,6 +1243,9 @@ class _ChatScreenState extends State { unawaited(_showTabLog(tab)); }), if (tab.type == ChatTabType.channel) ...[ + action('Channel settings', Icons.settings_outlined, () { + unawaited(_openChannelSettings(tab)); + }), action('Channel note', Icons.sticky_note_2_outlined, () { unawaited(_showChannelNoteDialog(tab)); }), @@ -1264,6 +1278,19 @@ class _ChatScreenState extends State { ); } + Future _openChannelSettings(ChatTab tab) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChannelSettingsScreen( + controller: _controller, + tab: tab, + networkController: widget.networkController, + notesRepository: _channelNotesRepository, + ), + ), + ); + } + Future _showTabLog(ChatTab tab) async { final messages = _controller.messagesForTab(tab.id); await showDialog( @@ -1478,12 +1505,17 @@ class _ChatScreenState extends State { String nick, ChannelModerationAction action, ) async { + final presetReasons = await KickBanReasonsRepository().loadReasons(); + if (!mounted) { + return; + } final request = await showDialog<_KickBanRequest>( context: context, builder: (context) => _KickBanDialog( nick: nick, action: action, maskPreview: (type) => _controller.banMaskPreviewForNick(nick, type), + presetReasons: presetReasons, ), ); if (request == null) { @@ -2553,24 +2585,22 @@ class _KickBanDialog extends StatefulWidget { required this.nick, required this.action, required this.maskPreview, + this.presetReasons = KickBanReasonsRepository.defaultReasons, }); final String nick; final ChannelModerationAction action; final String Function(int type) maskPreview; + final List presetReasons; @override State<_KickBanDialog> createState() => _KickBanDialogState(); } class _KickBanDialogState extends State<_KickBanDialog> { - static const List _presetReasons = [ + late final List _presetReasons = [ '', - 'Spam', - 'Flooding', - 'Abuse', - 'Off-topic', - 'Policy violation', + ...widget.presetReasons, ]; final TextEditingController _reason = TextEditingController(); diff --git a/lib/features/chat/presentation/join_channel_dialog.dart b/lib/features/chat/presentation/join_channel_dialog.dart index 5868d17..4a24759 100644 --- a/lib/features/chat/presentation/join_channel_dialog.dart +++ b/lib/features/chat/presentation/join_channel_dialog.dart @@ -1,9 +1,7 @@ import 'package:flutter/material.dart'; class JoinChannelRequest { - const JoinChannelRequest({ - required this.channel, - }); + const JoinChannelRequest({required this.channel}); final String channel; } @@ -46,9 +44,9 @@ class _JoinChannelDialogState extends State { ), FilledButton( onPressed: () { - Navigator.of(context).pop( - JoinChannelRequest(channel: _controller.text.trim()), - ); + Navigator.of( + context, + ).pop(JoinChannelRequest(channel: _controller.text.trim())); }, child: const Text('Join'), ), diff --git a/lib/features/connections/application/network_list_controller.dart b/lib/features/connections/application/network_list_controller.dart index ff48d38..67389ab 100644 --- a/lib/features/connections/application/network_list_controller.dart +++ b/lib/features/connections/application/network_list_controller.dart @@ -139,6 +139,43 @@ class NetworkListController extends ChangeNotifier { await load(); } + /// Adds or removes [channel] from a network's auto-join list without + /// touching any other config (used by the per-channel settings screen). + Future setChannelAutoJoin({ + required String networkId, + required String channel, + required bool autoJoin, + }) async { + final networks = await _repository.loadNetworks(); + NetworkConfig? network; + for (final item in networks) { + if (item.id == networkId) { + network = item; + break; + } + } + if (network == null) { + return; + } + final normalized = channel.startsWith('#') ? channel : '#$channel'; + final channels = [...network.autoJoinChannels]; + final already = channels.any( + (item) => item.toLowerCase() == normalized.toLowerCase(), + ); + if (autoJoin == already) { + return; + } + if (autoJoin) { + channels.add(normalized); + } else { + channels.removeWhere( + (item) => item.toLowerCase() == normalized.toLowerCase(), + ); + } + await _repository.saveNetwork(network.copyWith(autoJoinChannels: channels)); + await load(); + } + String _createId(String seed) { final normalized = seed.toLowerCase().replaceAll( RegExp(r'[^a-z0-9]+'), diff --git a/lib/features/connections/data/pem_bundle.dart b/lib/features/connections/data/pem_bundle.dart index 4a91683..238dbaf 100644 --- a/lib/features/connections/data/pem_bundle.dart +++ b/lib/features/connections/data/pem_bundle.dart @@ -23,7 +23,10 @@ class PemBundle { /// [text]. Returns an empty bundle when no PEM blocks are present (e.g. the /// file is binary DER/PKCS#12). static PemBundle parse(String text) { - final certs = _certificate.allMatches(text).map((m) => m.group(0)!).toList(); + final certs = _certificate + .allMatches(text) + .map((m) => m.group(0)!) + .toList(); final keyMatch = _privateKey.firstMatch(text); return PemBundle( certificate: certs.isEmpty ? null : certs.join('\n'), diff --git a/lib/features/connections/presentation/profiles_screen.dart b/lib/features/connections/presentation/profiles_screen.dart index 51c1b25..edb5807 100644 --- a/lib/features/connections/presentation/profiles_screen.dart +++ b/lib/features/connections/presentation/profiles_screen.dart @@ -165,8 +165,8 @@ class _ProfileEditorDialogState extends State<_ProfileEditorDialog> { return; } final existing = widget.profile; - final id = existing?.id ?? - 'profile-${DateTime.now().microsecondsSinceEpoch}'; + final id = + existing?.id ?? 'profile-${DateTime.now().microsecondsSinceEpoch}'; Navigator.of(context).pop( IdentityProfile( id: id, diff --git a/lib/features/onboarding/presentation/onboarding_screen.dart b/lib/features/onboarding/presentation/onboarding_screen.dart index 612e0de..9100c1e 100644 --- a/lib/features/onboarding/presentation/onboarding_screen.dart +++ b/lib/features/onboarding/presentation/onboarding_screen.dart @@ -134,7 +134,9 @@ class _OnboardingScreenState extends State { final nick = _nickname.text.trim().isEmpty ? 'AndroidIRCX' : _nickname.text.trim(); - final alt = _altNick.text.trim().isEmpty ? '${nick}_' : _altNick.text.trim(); + final alt = _altNick.text.trim().isEmpty + ? '${nick}_' + : _altNick.text.trim(); final realName = _realName.text.trim().isEmpty ? 'AndroidIRCX User' : _realName.text.trim(); @@ -300,7 +302,10 @@ class _OnboardingScreenState extends State { color: theme.colorScheme.primary, ), const SizedBox(height: 16), - Text('Stay reachable in the background', style: theme.textTheme.titleMedium), + Text( + 'Stay reachable in the background', + style: theme.textTheme.titleMedium, + ), const SizedBox(height: 8), Text( 'Allow notifications so highlights and private messages can alert you, ' @@ -379,9 +384,7 @@ class _OnboardingScreenState extends State { const SizedBox(height: 12), OutlinedButton.icon( onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const DataPrivacyScreen(), - ), + MaterialPageRoute(builder: (_) => const DataPrivacyScreen()), ), icon: const Icon(Icons.privacy_tip_outlined), label: const Text('Read data & privacy details'), diff --git a/lib/features/settings/presentation/backup_screen.dart b/lib/features/settings/presentation/backup_screen.dart index 3bef3e4..fe3dc25 100644 --- a/lib/features/settings/presentation/backup_screen.dart +++ b/lib/features/settings/presentation/backup_screen.dart @@ -27,7 +27,8 @@ class _BackupScreenState extends State { @override void initState() { super.initState(); - _service = widget.service ?? + _service = + widget.service ?? BackupService( networkRepository: SharedPrefsNetworkRepository( secretStorage: FlutterSecureSecretStorage(), @@ -79,9 +80,7 @@ class _BackupScreenState extends State { ), ); } catch (error) { - messenger.showSnackBar( - SnackBar(content: Text('Import failed: $error')), - ); + messenger.showSnackBar(SnackBar(content: Text('Import failed: $error'))); } finally { if (mounted) { setState(() => _busy = false); diff --git a/lib/features/settings/presentation/command_aliases_screen.dart b/lib/features/settings/presentation/command_aliases_screen.dart new file mode 100644 index 0000000..265eb8f --- /dev/null +++ b/lib/features/settings/presentation/command_aliases_screen.dart @@ -0,0 +1,182 @@ +import 'dart:async'; + +import 'package:androidircx/features/chat/application/command_service.dart'; +import 'package:flutter/material.dart'; + +/// Manage user-defined command aliases (e.g. /gm → /msg GameMaster). +/// Built-in shortcuts are listed read-only and can be shadowed. +class CommandAliasesScreen extends StatefulWidget { + const CommandAliasesScreen({super.key, this.commandService}); + + /// Overridable for tests; defaults to a fresh service over the shared + /// alias storage. + final CommandService? commandService; + + @override + State createState() => _CommandAliasesScreenState(); +} + +class _CommandAliasesScreenState extends State { + late final CommandService _service; + bool _loaded = false; + + @override + void initState() { + super.initState(); + _service = widget.commandService ?? CommandService(); + unawaited(_load()); + } + + Future _load() async { + await _service.load(); + if (mounted) { + setState(() => _loaded = true); + } + } + + Future _editAlias({String? alias, String? command}) async { + final result = await showDialog<({String alias, String command})>( + context: context, + builder: (context) => _AliasEditDialog(alias: alias, command: command), + ); + if (result == null || !mounted) { + return; + } + final error = await _service.setAlias(result.alias, result.command); + if (!mounted) { + return; + } + if (error != null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error))); + return; + } + setState(() {}); + } + + Future _removeAlias(String alias) async { + await _service.removeAlias(alias); + if (mounted) { + setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + final aliases = _loaded ? _service.aliases : const []; + return Scaffold( + appBar: AppBar(title: const Text('Command aliases')), + floatingActionButton: FloatingActionButton( + key: const Key('alias-add'), + onPressed: () => unawaited(_editAlias()), + tooltip: 'Add alias', + child: const Icon(Icons.add), + ), + body: SafeArea( + child: !_loaded + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + for (final alias in aliases) + ListTile( + key: Key('alias-row-${alias.alias}'), + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.bolt_outlined), + title: Text('/${alias.alias}'), + subtitle: Text( + _service.isCustomAlias(alias.alias) + ? alias.command + : '${alias.command} • built-in', + ), + onTap: () => unawaited( + _editAlias(alias: alias.alias, command: alias.command), + ), + trailing: _service.isCustomAlias(alias.alias) + ? IconButton( + key: Key('alias-remove-${alias.alias}'), + tooltip: 'Remove', + icon: const Icon(Icons.delete_outline), + onPressed: () => + unawaited(_removeAlias(alias.alias)), + ) + : null, + ), + ], + ), + ), + ); + } +} + +/// Owns its text controllers so they outlive the dialog's exit animation. +class _AliasEditDialog extends StatefulWidget { + const _AliasEditDialog({this.alias, this.command}); + + final String? alias; + final String? command; + + @override + State<_AliasEditDialog> createState() => _AliasEditDialogState(); +} + +class _AliasEditDialogState extends State<_AliasEditDialog> { + late final TextEditingController _aliasController = TextEditingController( + text: widget.alias ?? '', + ); + late final TextEditingController _commandController = TextEditingController( + text: widget.command ?? '', + ); + + @override + void dispose() { + _aliasController.dispose(); + _commandController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.alias == null ? 'Add alias' : 'Edit alias'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + key: const Key('alias-name-field'), + controller: _aliasController, + enabled: widget.alias == null, + decoration: const InputDecoration( + labelText: 'Alias', + helperText: 'One word, e.g. gm', + ), + ), + const SizedBox(height: 12), + TextField( + key: const Key('alias-command-field'), + controller: _commandController, + decoration: const InputDecoration( + labelText: 'Command', + helperText: 'e.g. /msg GameMaster', + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + key: const Key('alias-save'), + onPressed: () => Navigator.of(context).pop(( + alias: _aliasController.text, + command: _commandController.text, + )), + child: const Text('Save'), + ), + ], + ); + } +} diff --git a/lib/features/settings/presentation/crash_reports_screen.dart b/lib/features/settings/presentation/crash_reports_screen.dart index 4150100..f11622a 100644 --- a/lib/features/settings/presentation/crash_reports_screen.dart +++ b/lib/features/settings/presentation/crash_reports_screen.dart @@ -64,9 +64,9 @@ class _CrashReportsScreenState extends State { if (!mounted) { return; } - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Crash reports cleared.')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Crash reports cleared.'))); } @override diff --git a/lib/features/settings/presentation/kick_ban_reasons_screen.dart b/lib/features/settings/presentation/kick_ban_reasons_screen.dart new file mode 100644 index 0000000..679c629 --- /dev/null +++ b/lib/features/settings/presentation/kick_ban_reasons_screen.dart @@ -0,0 +1,150 @@ +import 'dart:async'; + +import 'package:androidircx/features/chat/data/kick_ban_reasons_repository.dart'; +import 'package:flutter/material.dart'; + +/// Manage the preset kick/ban reasons offered by the moderation dialog. +class KickBanReasonsScreen extends StatefulWidget { + const KickBanReasonsScreen({super.key, this.repository}); + + final KickBanReasonsRepository? repository; + + @override + State createState() => _KickBanReasonsScreenState(); +} + +class _KickBanReasonsScreenState extends State { + late final KickBanReasonsRepository _repository; + List _reasons = const []; + bool _loaded = false; + + @override + void initState() { + super.initState(); + _repository = widget.repository ?? KickBanReasonsRepository(); + unawaited(_load()); + } + + Future _load() async { + final reasons = await _repository.loadReasons(); + if (!mounted) { + return; + } + setState(() { + _reasons = reasons; + _loaded = true; + }); + } + + Future _save(List next) async { + setState(() => _reasons = next); + await _repository.saveReasons(next); + } + + Future _addReason() async { + final reason = await showDialog( + context: context, + builder: (context) => const _AddReasonDialog(), + ); + final trimmed = (reason ?? '').trim(); + if (trimmed.isEmpty || !mounted) { + return; + } + if (_reasons.any((item) => item.toLowerCase() == trimmed.toLowerCase())) { + return; + } + await _save([..._reasons, trimmed]); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Kick/ban reasons'), + actions: [ + IconButton( + key: const Key('kick-ban-reasons-reset'), + tooltip: 'Restore defaults', + icon: const Icon(Icons.restore), + onPressed: () => + unawaited(_save(KickBanReasonsRepository.defaultReasons)), + ), + ], + ), + floatingActionButton: FloatingActionButton( + key: const Key('kick-ban-reason-add'), + onPressed: () => unawaited(_addReason()), + tooltip: 'Add reason', + child: const Icon(Icons.add), + ), + body: SafeArea( + child: !_loaded + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + for (final reason in _reasons) + ListTile( + key: Key('kick-ban-reason-row-$reason'), + contentPadding: EdgeInsets.zero, + title: Text(reason), + trailing: IconButton( + key: Key('kick-ban-reason-remove-$reason'), + tooltip: 'Remove', + icon: const Icon(Icons.delete_outline), + onPressed: () => unawaited( + _save([ + for (final item in _reasons) + if (item != reason) item, + ]), + ), + ), + ), + ], + ), + ), + ); + } +} + +/// Owns its text controller so it outlives the dialog's exit animation. +class _AddReasonDialog extends StatefulWidget { + const _AddReasonDialog(); + + @override + State<_AddReasonDialog> createState() => _AddReasonDialogState(); +} + +class _AddReasonDialogState extends State<_AddReasonDialog> { + final TextEditingController _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Add reason'), + content: TextField( + key: const Key('kick-ban-reason-field'), + controller: _controller, + autofocus: true, + decoration: const InputDecoration(labelText: 'Reason'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + key: const Key('kick-ban-reason-save'), + onPressed: () => Navigator.of(context).pop(_controller.text), + child: const Text('Add'), + ), + ], + ); + } +} diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index 6d1795a..ef70af9 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -13,6 +13,8 @@ import 'package:androidircx/features/connections/presentation/server_directory_p import 'package:androidircx/features/monetization/presentation/purchase_screen.dart'; import 'package:androidircx/features/onboarding/presentation/data_privacy_screen.dart'; import 'package:androidircx/features/settings/presentation/backup_screen.dart'; +import 'package:androidircx/features/settings/presentation/command_aliases_screen.dart'; +import 'package:androidircx/features/settings/presentation/kick_ban_reasons_screen.dart'; import 'package:androidircx/features/settings/presentation/crash_reports_screen.dart'; import 'package:androidircx/features/settings/presentation/message_format_screen.dart'; import 'package:androidircx/features/settings/presentation/sound_settings_screen.dart'; @@ -680,6 +682,20 @@ class _SettingsScreenState extends State { _settings.copyWith(showSendButton: value), ), ), + const Divider(height: 1), + ListTile( + key: const Key('settings-command-aliases'), + leading: const Icon(Icons.bolt_outlined), + title: const Text('Command aliases'), + subtitle: const Text( + 'Shortcuts like /j for /join; add your own.', + ), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const CommandAliasesScreen(), + ), + ), + ), ], ), const SizedBox(height: 12), @@ -823,6 +839,20 @@ class _SettingsScreenState extends State { _settings.copyWith(autoRejoinOnKick: value), ), ), + const Divider(height: 1), + ListTile( + key: const Key('settings-kick-ban-reasons'), + leading: const Icon(Icons.gavel_outlined), + title: const Text('Kick/ban reasons'), + subtitle: const Text( + 'Preset reasons offered by the moderation dialog.', + ), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const KickBanReasonsScreen(), + ), + ), + ), ], ), const SizedBox(height: 12), diff --git a/lib/features/settings/presentation/theme_editor_screen.dart b/lib/features/settings/presentation/theme_editor_screen.dart index 05d6177..6cb1acb 100644 --- a/lib/features/settings/presentation/theme_editor_screen.dart +++ b/lib/features/settings/presentation/theme_editor_screen.dart @@ -88,10 +88,7 @@ class _ThemeEditorScreenState extends State { Future _editColor(String key, String label) async { final picked = await showDialog( context: context, - builder: (_) => _ColorPickerDialog( - label: label, - initial: _colorFor(key), - ), + builder: (_) => _ColorPickerDialog(label: label, initial: _colorFor(key)), ); if (picked != null) { setState(() => _theme[key] = _colorToHex(picked)); diff --git a/lib/irc/parser/ctcp.dart b/lib/irc/parser/ctcp.dart index 6e9b2a4..7f2f60c 100644 --- a/lib/irc/parser/ctcp.dart +++ b/lib/irc/parser/ctcp.dart @@ -1,9 +1,5 @@ class CtcpMessage { - const CtcpMessage({ - required this.isCtcp, - this.command, - this.args, - }); + const CtcpMessage({required this.isCtcp, this.command, this.args}); final bool isCtcp; final String? command; @@ -13,7 +9,8 @@ class CtcpMessage { const _ctcpDelimiter = '\u0001'; CtcpMessage parseCtcp(String message) { - if (!message.startsWith(_ctcpDelimiter) || !message.endsWith(_ctcpDelimiter)) { + if (!message.startsWith(_ctcpDelimiter) || + !message.endsWith(_ctcpDelimiter)) { return const CtcpMessage(isCtcp: false); } diff --git a/lib/irc/parser/irc_url_parser.dart b/lib/irc/parser/irc_url_parser.dart index 9a757cd..99df205 100644 --- a/lib/irc/parser/irc_url_parser.dart +++ b/lib/irc/parser/irc_url_parser.dart @@ -39,19 +39,22 @@ bool isIrcUrl(String url) { ParsedIrcUrl parseIrcUrl(String url) { ParsedIrcUrl invalid(String error) => ParsedIrcUrl( - protocol: 'irc', - server: '', - port: 6667, - ssl: false, - isValid: false, - error: error, - ); + protocol: 'irc', + server: '', + port: 6667, + ssl: false, + isValid: false, + error: error, + ); if (url.trim().isEmpty) { return invalid('URL is empty or invalid'); } - final match = RegExp(r'^(irc|ircs):\/\/', caseSensitive: false).firstMatch(url.trim()); + final match = RegExp( + r'^(irc|ircs):\/\/', + caseSensitive: false, + ).firstMatch(url.trim()); if (match == null) { return invalid('Invalid IRC URL format. Expected: irc:// or ircs://'); } diff --git a/lib/irc/parser/mirc_preset_parser.dart b/lib/irc/parser/mirc_preset_parser.dart index 8cdc50e..04c26fb 100644 --- a/lib/irc/parser/mirc_preset_parser.dart +++ b/lib/irc/parser/mirc_preset_parser.dart @@ -1,11 +1,7 @@ import 'dart:convert'; class MircPresetEntry { - const MircPresetEntry({ - required this.id, - required this.raw, - this.enabled, - }); + const MircPresetEntry({required this.id, required this.raw, this.enabled}); final String id; final String raw; @@ -83,7 +79,10 @@ List parseNickCompletionPresets(String raw) { final lines = splitPresetLines(raw); return List.generate(lines.length, (index) { final line = lines[index]; - final match = RegExp(r'(\s+|\x08)(on|off)$', caseSensitive: false).firstMatch(line); + final match = RegExp( + r'(\s+|\x08)(on|off)$', + caseSensitive: false, + ).firstMatch(line); if (match == null) { return MircPresetEntry(id: 'nick-${index + 1}', raw: line); } diff --git a/lib/irc/services/irc_transport_connector_web.dart b/lib/irc/services/irc_transport_connector_web.dart index fc2c6aa..ff569de 100644 --- a/lib/irc/services/irc_transport_connector_web.dart +++ b/lib/irc/services/irc_transport_connector_web.dart @@ -14,11 +14,8 @@ Future connectDefaultTransport( } class WebIrcTransport implements IrcTransport { - WebIrcTransport._( - this._incoming, - this._send, - this._onClose, - ) : _lineController = StreamController.broadcast() { + WebIrcTransport._(this._incoming, this._send, this._onClose) + : _lineController = StreamController.broadcast() { _messageSubscription = _incoming.listen( (data) { for (final line in framesFromMessage(data)) { diff --git a/test/attachment_tap_action_test.dart b/test/attachment_tap_action_test.dart index b1e117c..4a3cd79 100644 --- a/test/attachment_tap_action_test.dart +++ b/test/attachment_tap_action_test.dart @@ -2,10 +2,8 @@ import 'package:androidircx/core/models/irc_message.dart'; import 'package:androidircx/features/chat/presentation/chat_screen.dart'; import 'package:flutter_test/flutter_test.dart'; -IrcMessageAttachment att( - IrcMessageAttachmentType type, { - String? uri, -}) => IrcMessageAttachment(type: type, label: '', uri: uri); +IrcMessageAttachment att(IrcMessageAttachmentType type, {String? uri}) => + IrcMessageAttachment(type: type, label: '', uri: uri); void main() { test('null uri means no action', () { diff --git a/test/auto_mode_test.dart b/test/auto_mode_test.dart index 1971bb2..299c6f7 100644 --- a/test/auto_mode_test.dart +++ b/test/auto_mode_test.dart @@ -84,10 +84,7 @@ void main() { transport.emit(':bob!id@host JOIN #flutter'); await Future.delayed(Duration.zero); - expect( - transport.sentLines.where((l) => l.startsWith('MODE')), - isEmpty, - ); + expect(transport.sentLines.where((l) => l.startsWith('MODE')), isEmpty); controller.dispose(); }); @@ -107,9 +104,7 @@ void main() { await controller.handleComposerSubmit('/unautovoice bob'); expect( - controller.autoModeEntries.where( - (e) => e.type == UserListType.autoVoice, - ), + controller.autoModeEntries.where((e) => e.type == UserListType.autoVoice), isEmpty, ); expect(controller.autoModeEntries, hasLength(1)); diff --git a/test/backup_service_test.dart b/test/backup_service_test.dart index b51972a..328478b 100644 --- a/test/backup_service_test.dart +++ b/test/backup_service_test.dart @@ -33,9 +33,7 @@ void main() { saslPassword: 'saslsecret', ), ]); - final settings = _FakeSettings( - const AppSettings(monospaceMessages: true), - ); + final settings = _FakeSettings(const AppSettings(monospaceMessages: true)); final profiles = InMemoryIdentityProfileRepository([ const IdentityProfile(id: 'p', name: 'Work', nick: 'w'), ]); @@ -68,10 +66,7 @@ void main() { expect(restored.single.name, 'N'); expect(restored.single.password, isNull); expect((await settings2.loadSettings()).monospaceMessages, isTrue); - expect( - (await profiles2.loadProfiles()).any((p) => p.id == 'p'), - isTrue, - ); + expect((await profiles2.loadProfiles()).any((p) => p.id == 'p'), isTrue); }); test('rejects a non-object backup', () async { diff --git a/test/certificate_store_test.dart b/test/certificate_store_test.dart index ee3c261..eb2d627 100644 --- a/test/certificate_store_test.dart +++ b/test/certificate_store_test.dart @@ -4,10 +4,12 @@ import 'package:androidircx/core/security/secret_storage.dart'; import 'package:androidircx/core/storage/network_secret_keys.dart'; import 'package:flutter_test/flutter_test.dart'; -const _certPem = '-----BEGIN CERTIFICATE-----\n' +const _certPem = + '-----BEGIN CERTIFICATE-----\n' 'MIIBmockCertBody0123456789ABCDEFabcdef+/==\n' '-----END CERTIFICATE-----'; -const _keyPem = '-----BEGIN PRIVATE KEY-----\n' +const _keyPem = + '-----BEGIN PRIVATE KEY-----\n' 'MIIEmockKeyBody0123456789ABCDEFabcdef+/==\n' '-----END PRIVATE KEY-----'; @@ -19,7 +21,10 @@ void main() { await store.save( 'net-1', - const ClientCertificate(certificatePem: _certPem, privateKeyPem: _keyPem), + const ClientCertificate( + certificatePem: _certPem, + privateKeyPem: _keyPem, + ), ); expect(await store.has('net-1'), isTrue); @@ -96,7 +101,8 @@ void main() { () => validateClientCertificate( const ClientCertificate( certificatePem: _certPem, - privateKeyPem: '-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----', + privateKeyPem: + '-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----', ), ), throwsA(isA()), @@ -104,11 +110,15 @@ void main() { }); test('accepts RSA and EC private-key PEM labels', () { - const rsaKey = '-----BEGIN RSA PRIVATE KEY-----\nAAAABBBBCCCC+/==\n' + const rsaKey = + '-----BEGIN RSA PRIVATE KEY-----\nAAAABBBBCCCC+/==\n' '-----END RSA PRIVATE KEY-----'; expect( () => validateClientCertificate( - const ClientCertificate(certificatePem: _certPem, privateKeyPem: rsaKey), + const ClientCertificate( + certificatePem: _certPem, + privateKeyPem: rsaKey, + ), ), returnsNormally, ); @@ -142,7 +152,10 @@ void main() { ); await store.save( 'net-1', - const ClientCertificate(certificatePem: _certPem, privateKeyPem: _keyPem), + const ClientCertificate( + certificatePem: _certPem, + privateKeyPem: _keyPem, + ), ); final read = await store.read('net-1'); expect(read!.isPkcs12, isFalse); diff --git a/test/channel_settings_test.dart b/test/channel_settings_test.dart new file mode 100644 index 0000000..da3c626 --- /dev/null +++ b/test/channel_settings_test.dart @@ -0,0 +1,108 @@ +import 'dart:async'; + +import 'package:androidircx/core/models/network_config.dart'; +import 'package:androidircx/core/storage/in_memory_network_repository.dart'; +import 'package:androidircx/features/chat/application/chat_session_controller.dart'; +import 'package:androidircx/features/chat/data/channel_notes_repository.dart'; +import 'package:androidircx/features/chat/presentation/channel_settings_screen.dart'; +import 'package:androidircx/features/chat/presentation/join_channel_dialog.dart'; +import 'package:androidircx/features/connections/application/network_list_controller.dart'; +import 'package:androidircx/irc/services/irc_service.dart'; +import 'package:androidircx/irc/services/irc_transport.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _FakeTransport implements IrcTransport { + final StreamController _controller = + StreamController.broadcast(); + + @override + Stream get lines => _controller.stream; + + @override + Future close() async { + await _controller.close(); + } + + @override + Future sendLine(String line) async {} + + void emit(String line) { + _controller.add(line); + } +} + +void main() { + testWidgets('channel settings edits auto-join and note', (tester) async { + SharedPreferences.setMockInitialValues({}); + const network = NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.dbase.in.rs', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + ); + final transport = _FakeTransport(); + final controller = ChatSessionController( + network: network, + ircService: IrcService(transportConnector: (_) async => transport), + ); + final repository = InMemoryNetworkRepository([network]); + final networkController = NetworkListController(repository: repository); + + await controller.start(); + await controller.joinChannel(const JoinChannelRequest(channel: '#room')); + transport.emit(':server 332 AndroidIRCX #room :Welcome to the room'); + transport.emit(':alice!user@example PRIVMSG #room :hi all'); + // Flush the stream microtasks inside the fake-async test zone. + await tester.idle(); + + final tab = controller.tabs.firstWhere((item) => item.name == '#room'); + + await tester.pumpWidget( + MaterialApp( + home: ChannelSettingsScreen( + controller: controller, + tab: tab, + networkController: networkController, + notesRepository: ChannelNotesRepository(), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + // Topic and recent log are shown. + expect(find.text('Welcome to the room'), findsOneWidget); + expect(find.textContaining('hi all'), findsWidgets); + + // Toggle auto-join and check the stored network config. + await tester.tap(find.byKey(const Key('channel-settings-auto-join'))); + await tester.pumpAndSettle(); + var saved = (await repository.loadNetworks()).single; + expect(saved.autoJoinChannels, contains('#room')); + + // Save a note and read it back through the repository. + await tester.enterText( + find.byKey(const Key('channel-settings-note')), + 'ops meeting every friday', + ); + await tester.tap(find.byKey(const Key('channel-settings-save-note'))); + await tester.pumpAndSettle(); + expect( + await ChannelNotesRepository().getNote('dbase', '#room'), + 'ops meeting every friday', + ); + + // Toggle auto-join back off. + await tester.tap(find.byKey(const Key('channel-settings-auto-join'))); + await tester.pumpAndSettle(); + saved = (await repository.loadNetworks()).single; + expect(saved.autoJoinChannels, isNot(contains('#room'))); + + controller.dispose(); + networkController.dispose(); + }); +} diff --git a/test/chat_screen_landscape_test.dart b/test/chat_screen_landscape_test.dart index c029392..2aab5ad 100644 --- a/test/chat_screen_landscape_test.dart +++ b/test/chat_screen_landscape_test.dart @@ -114,8 +114,9 @@ void main() { ); await tester.pump(); transports.single.emit(':server 001 AndroidIRCX :Welcome'); - transports.single - .emit(':server NOTICE AndroidIRCX :server message on the tab'); + transports.single.emit( + ':server NOTICE AndroidIRCX :server message on the tab', + ); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); diff --git a/test/chat_session_controller_test.dart b/test/chat_session_controller_test.dart index 5a632c9..d233a98 100644 --- a/test/chat_session_controller_test.dart +++ b/test/chat_session_controller_test.dart @@ -2110,6 +2110,54 @@ void main() { controller.dispose(); }); + test( + 'queues messages typed while disconnected and flushes on connect', + () async { + final transport = _FakeTransport(); + final service = IrcService(transportConnector: (_) async => transport); + final controller = ChatSessionController( + network: const NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + ), + ircService: service, + ); + + // Open a query tab and type while still disconnected. + await controller.handleComposerSubmit('/query alice'); + await controller.handleComposerSubmit('first offline'); + await controller.handleComposerSubmit('second offline'); + expect(controller.queuedOfflineMessages, 2); + expect( + transport.sentLines.where((line) => line.contains('offline')), + isEmpty, + ); + + await controller.start(); + transport.emit(':server 001 AndroidIRCX :Welcome'); + // Post-registration actions (including the outbox flush) run at the + // end of the MOTD. + transport.emit(':server 422 AndroidIRCX :MOTD File is missing'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(controller.queuedOfflineMessages, 0); + final sent = transport.sentLines + .where((line) => line.startsWith('PRIVMSG alice')) + .toList(); + expect(sent, [ + 'PRIVMSG alice :first offline', + 'PRIVMSG alice :second offline', + ]); + + controller.dispose(); + }, + ); + test('handles account away host and setname user-state frames', () async { final transport = _FakeTransport(); final service = IrcService(transportConnector: (_) async => transport); diff --git a/test/command_aliases_test.dart b/test/command_aliases_test.dart new file mode 100644 index 0000000..bbf7ecf --- /dev/null +++ b/test/command_aliases_test.dart @@ -0,0 +1,98 @@ +import 'package:androidircx/features/chat/application/command_service.dart'; +import 'package:androidircx/features/settings/presentation/command_aliases_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + group('CommandService aliases', () { + test('custom alias expands in normalizeCommand and persists', () async { + final service = CommandService(); + await service.load(); + + expect(await service.setAlias('gm', '/msg GameMaster'), isNull); + expect( + service.normalizeCommand('/gm hello there'), + '/msg GameMaster hello there', + ); + + // A fresh service instance sees the persisted alias. + final reloaded = CommandService(); + await reloaded.load(); + expect(reloaded.normalizeCommand('/gm hi'), '/msg GameMaster hi'); + expect(reloaded.isCustomAlias('gm'), isTrue); + }); + + test( + 'custom alias can shadow a built-in and removal restores it', + () async { + final service = CommandService(); + await service.load(); + + expect(service.normalizeCommand('/j #a'), '/join #a'); + expect(await service.setAlias('j', '/join #androidircx'), isNull); + expect(service.normalizeCommand('/j'), '/join #androidircx'); + + await service.removeAlias('j'); + expect(service.normalizeCommand('/j #a'), '/join #a'); + expect(service.isCustomAlias('j'), isFalse); + expect(service.isBuiltInAlias('j'), isTrue); + }, + ); + + test('rejects invalid aliases and command-name collisions', () async { + final service = CommandService(); + await service.load(); + + expect(await service.setAlias('two words', '/join'), isNotNull); + expect(await service.setAlias('', '/join'), isNotNull); + // 'join' is an actual command, not allowed as an alias. + expect(await service.setAlias('join', '/part'), isNotNull); + // Command without leading slash gets normalized instead of rejected. + expect(await service.setAlias('gg', 'msg GG'), isNull); + expect(service.normalizeCommand('/gg'), '/msg GG'); + }); + }); + + group('CommandAliasesScreen', () { + testWidgets('adds and removes a custom alias', (tester) async { + final service = CommandService(); + await tester.pumpWidget( + MaterialApp(home: CommandAliasesScreen(commandService: service)), + ); + await tester.pumpAndSettle(); + + // Built-in aliases are listed without a remove button. + expect(find.text('/j'), findsOneWidget); + expect(find.byKey(const Key('alias-remove-j')), findsNothing); + + await tester.tap(find.byKey(const Key('alias-add'))); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const Key('alias-name-field')), 'gm'); + await tester.enterText( + find.byKey(const Key('alias-command-field')), + '/msg GameMaster', + ); + await tester.tap(find.byKey(const Key('alias-save'))); + await tester.pumpAndSettle(); + + expect(find.text('/gm'), findsOneWidget); + expect(service.normalizeCommand('/gm hi'), '/msg GameMaster hi'); + + await tester.scrollUntilVisible( + find.byKey(const Key('alias-remove-gm')), + 150, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap(find.byKey(const Key('alias-remove-gm'))); + await tester.pumpAndSettle(); + expect(find.text('/gm'), findsNothing); + }); + }); +} diff --git a/test/crash_reporter_test.dart b/test/crash_reporter_test.dart index 5aa65d4..28aef38 100644 --- a/test/crash_reporter_test.dart +++ b/test/crash_reporter_test.dart @@ -143,20 +143,24 @@ void main() { expect(await reporter.loadReports(), isEmpty); }); - test('builds a mailto uri addressed to the contact with subject/body', - () async { - final reporter = build(); - final report = await reporter.record( - Exception('kaboom'), - null, - source: 'test', - ); - final uri = reporter.buildMailtoUri(report!); - expect(uri.scheme, 'mailto'); - expect(uri.path, 'contact@androidircx.com'); - expect(uri.query, contains('subject=AndroidIRCX%20Crash%20Report')); - expect(Uri.decodeComponent(uri.queryParameters['body']!), - contains('kaboom')); - }); + test( + 'builds a mailto uri addressed to the contact with subject/body', + () async { + final reporter = build(); + final report = await reporter.record( + Exception('kaboom'), + null, + source: 'test', + ); + final uri = reporter.buildMailtoUri(report!); + expect(uri.scheme, 'mailto'); + expect(uri.path, 'contact@androidircx.com'); + expect(uri.query, contains('subject=AndroidIRCX%20Crash%20Report')); + expect( + Uri.decodeComponent(uri.queryParameters['body']!), + contains('kaboom'), + ); + }, + ); }); } diff --git a/test/crash_reports_screen_test.dart b/test/crash_reports_screen_test.dart index eb64875..1dcb4eb 100644 --- a/test/crash_reports_screen_test.dart +++ b/test/crash_reports_screen_test.dart @@ -62,9 +62,7 @@ void main() { final r = reporter(); await r.record(Exception('x'), null, source: 'test'); - await tester.pumpWidget( - MaterialApp(home: CrashReportsScreen(reporter: r)), - ); + await tester.pumpWidget(MaterialApp(home: CrashReportsScreen(reporter: r))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('crash-reports-clear'))); diff --git a/test/ctcp_test.dart b/test/ctcp_test.dart index 7d91253..d3e3b06 100644 --- a/test/ctcp_test.dart +++ b/test/ctcp_test.dart @@ -27,9 +27,6 @@ void main() { }); test('encodes CTCP command', () { - expect( - encodeCtcp('ping', '123'), - '\u0001PING 123\u0001', - ); + expect(encodeCtcp('ping', '123'), '\u0001PING 123\u0001'); }); } diff --git a/test/history_encryption_key_manager_test.dart b/test/history_encryption_key_manager_test.dart index 6719169..99f3898 100644 --- a/test/history_encryption_key_manager_test.dart +++ b/test/history_encryption_key_manager_test.dart @@ -49,19 +49,21 @@ void main() { expect(base64Decode(key!).length, 32); }); - test('returns null and provisions nothing when authentication fails', - () async { - final storage = InMemorySecretStorage(); - final manager = HistoryEncryptionKeyManager( - storage: storage, - authenticator: _FakeAuthenticator(false), - ); - - final key = await manager.unlockKey(); - expect(key, isNull); - expect(await manager.hasKey(), isFalse); - expect(await storage.getAllSecretKeys(), isEmpty); - }); + test( + 'returns null and provisions nothing when authentication fails', + () async { + final storage = InMemorySecretStorage(); + final manager = HistoryEncryptionKeyManager( + storage: storage, + authenticator: _FakeAuthenticator(false), + ); + + final key = await manager.unlockKey(); + expect(key, isNull); + expect(await manager.hasKey(), isFalse); + expect(await storage.getAllSecretKeys(), isEmpty); + }, + ); test('does not release an existing key without authentication', () async { final storage = InMemorySecretStorage(); @@ -80,18 +82,20 @@ void main() { expect(await manager.hasKey(), isTrue); }); - test('resetKey discards the key so history can no longer be opened', - () async { - final manager = HistoryEncryptionKeyManager( - storage: InMemorySecretStorage(), - authenticator: _FakeAuthenticator(true), - ); - await manager.unlockKey(); - expect(await manager.hasKey(), isTrue); - - await manager.resetKey(); - expect(await manager.hasKey(), isFalse); - }); + test( + 'resetKey discards the key so history can no longer be opened', + () async { + final manager = HistoryEncryptionKeyManager( + storage: InMemorySecretStorage(), + authenticator: _FakeAuthenticator(true), + ); + await manager.unlockKey(); + expect(await manager.hasKey(), isTrue); + + await manager.resetKey(); + expect(await manager.hasKey(), isFalse); + }, + ); test('passes the unlock reason through to the authenticator', () async { final auth = _FakeAuthenticator(true); diff --git a/test/history_payload_cipher_test.dart b/test/history_payload_cipher_test.dart index 5dff3fd..7b1bdf3 100644 --- a/test/history_payload_cipher_test.dart +++ b/test/history_payload_cipher_test.dart @@ -15,14 +15,17 @@ void main() { expect(await codec.decrypt(cipher), 'hello #room secret message'); }); - test('uses a fresh random nonce so equal plaintext differs on disk', () async { - final codec = AesGcmHistoryPayloadCodec.fromBase64Key(key); - final a = await codec.encrypt('same'); - final b = await codec.encrypt('same'); - expect(a, isNot(b)); - expect(await codec.decrypt(a), 'same'); - expect(await codec.decrypt(b), 'same'); - }); + test( + 'uses a fresh random nonce so equal plaintext differs on disk', + () async { + final codec = AesGcmHistoryPayloadCodec.fromBase64Key(key); + final a = await codec.encrypt('same'); + final b = await codec.encrypt('same'); + expect(a, isNot(b)); + expect(await codec.decrypt(a), 'same'); + expect(await codec.decrypt(b), 'same'); + }, + ); test('a wrong key cannot decrypt (tamper/theft protection)', () async { final codec = AesGcmHistoryPayloadCodec.fromBase64Key(key); diff --git a/test/identity_profile_test.dart b/test/identity_profile_test.dart index 9686557..f2c6f08 100644 --- a/test/identity_profile_test.dart +++ b/test/identity_profile_test.dart @@ -38,7 +38,10 @@ void main() { test('exposes the built-in default identity', () { expect(IdentityProfile.defaultProfile.nick, 'AndroidIRCX'); - expect(IdentityProfile.defaultProfile.id, IdentityProfile.defaultProfileId); + expect( + IdentityProfile.defaultProfile.id, + IdentityProfile.defaultProfileId, + ); }); }); @@ -87,27 +90,31 @@ void main() { expect(normalized.map((p) => p.id), contains('p1')); }); - test('in-memory repo saves, replaces, and deletes custom profiles', () async { - final repo = InMemoryIdentityProfileRepository(); - const profile = IdentityProfile(id: 'p1', name: 'Work', nick: 'alice'); - await repo.saveProfile(profile); - expect((await repo.loadProfiles()).any((p) => p.id == 'p1'), isTrue); + test( + 'in-memory repo saves, replaces, and deletes custom profiles', + () async { + final repo = InMemoryIdentityProfileRepository(); + const profile = IdentityProfile(id: 'p1', name: 'Work', nick: 'alice'); + await repo.saveProfile(profile); + expect((await repo.loadProfiles()).any((p) => p.id == 'p1'), isTrue); - await repo.saveProfile(profile.copyWith(nick: 'alice2')); - final loaded = await repo.loadProfiles(); - expect(loaded.firstWhere((p) => p.id == 'p1').nick, 'alice2'); - expect(loaded.where((p) => p.id == 'p1'), hasLength(1)); + await repo.saveProfile(profile.copyWith(nick: 'alice2')); + final loaded = await repo.loadProfiles(); + expect(loaded.firstWhere((p) => p.id == 'p1').nick, 'alice2'); + expect(loaded.where((p) => p.id == 'p1'), hasLength(1)); - await repo.deleteProfile('p1'); - expect((await repo.loadProfiles()).any((p) => p.id == 'p1'), isFalse); - }); + await repo.deleteProfile('p1'); + expect((await repo.loadProfiles()).any((p) => p.id == 'p1'), isFalse); + }, + ); test('the default profile cannot be deleted', () async { final repo = InMemoryIdentityProfileRepository(); await repo.deleteProfile(IdentityProfile.defaultProfileId); expect( - (await repo.loadProfiles()) - .any((p) => p.id == IdentityProfile.defaultProfileId), + (await repo.loadProfiles()).any( + (p) => p.id == IdentityProfile.defaultProfileId, + ), isTrue, ); }); diff --git a/test/irc_transport_web_framing_test.dart b/test/irc_transport_web_framing_test.dart index d74cba8..27025a5 100644 --- a/test/irc_transport_web_framing_test.dart +++ b/test/irc_transport_web_framing_test.dart @@ -7,22 +7,24 @@ import 'package:flutter_test/flutter_test.dart'; void main() { group('IRCv3 WebSocket framing', () { - test('decodes a single text frame without a trailing CRLF into one line', - () { - expect(WebIrcTransport.framesFromMessage('PING :abc'), ['PING :abc']); - }); + test( + 'decodes a single text frame without a trailing CRLF into one line', + () { + expect(WebIrcTransport.framesFromMessage('PING :abc'), ['PING :abc']); + }, + ); test('strips an optional trailing CRLF from a text frame', () { expect(WebIrcTransport.framesFromMessage('PING :abc\r\n'), ['PING :abc']); }); test('decodes a binary (byte) frame as UTF-8', () { - final bytes = - Uint8List.fromList(utf8.encode(':nick!u@h PRIVMSG #c :héllo\r\n')); - expect( - WebIrcTransport.framesFromMessage(bytes), - [':nick!u@h PRIVMSG #c :héllo'], + final bytes = Uint8List.fromList( + utf8.encode(':nick!u@h PRIVMSG #c :héllo\r\n'), ); + expect(WebIrcTransport.framesFromMessage(bytes), [ + ':nick!u@h PRIVMSG #c :héllo', + ]); }); test('splits a non-compliant frame that packs multiple CRLF lines', () { @@ -60,23 +62,25 @@ void main() { await incoming.close(); }); - test('sendLine terminates each outgoing message with a single CRLF', - () async { - final incoming = StreamController.broadcast(); - final sent = []; - final transport = WebIrcTransport.forTesting( - incoming: incoming.stream, - onSend: sent.add, - ); - - await transport.sendLine('NICK tester'); - await transport.sendLine('USER a 0 * :real'); - - expect(sent, ['NICK tester\r\n', 'USER a 0 * :real\r\n']); - - await transport.close(); - await incoming.close(); - }); + test( + 'sendLine terminates each outgoing message with a single CRLF', + () async { + final incoming = StreamController.broadcast(); + final sent = []; + final transport = WebIrcTransport.forTesting( + incoming: incoming.stream, + onSend: sent.add, + ); + + await transport.sendLine('NICK tester'); + await transport.sendLine('USER a 0 * :real'); + + expect(sent, ['NICK tester\r\n', 'USER a 0 * :real\r\n']); + + await transport.close(); + await incoming.close(); + }, + ); test('closes the line stream when the socket is done', () async { final incoming = StreamController(); diff --git a/test/kick_ban_reasons_test.dart b/test/kick_ban_reasons_test.dart new file mode 100644 index 0000000..7925e76 --- /dev/null +++ b/test/kick_ban_reasons_test.dart @@ -0,0 +1,54 @@ +import 'package:androidircx/features/chat/data/kick_ban_reasons_repository.dart'; +import 'package:androidircx/features/settings/presentation/kick_ban_reasons_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('repository defaults and round-trips custom reasons', () async { + final repository = KickBanReasonsRepository(); + expect( + await repository.loadReasons(), + KickBanReasonsRepository.defaultReasons, + ); + + await repository.saveReasons(['Trolling', ' ', 'Spam ']); + expect(await repository.loadReasons(), ['Trolling', 'Spam']); + }); + + testWidgets('screen adds, removes, and resets reasons', (tester) async { + await tester.pumpWidget(const MaterialApp(home: KickBanReasonsScreen())); + await tester.pumpAndSettle(); + + expect(find.text('Spam'), findsOneWidget); + + await tester.tap(find.byKey(const Key('kick-ban-reason-add'))); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const Key('kick-ban-reason-field')), + 'Trolling', + ); + await tester.tap(find.byKey(const Key('kick-ban-reason-save'))); + await tester.pumpAndSettle(); + expect(find.text('Trolling'), findsOneWidget); + expect( + await KickBanReasonsRepository().loadReasons(), + contains('Trolling'), + ); + + await tester.tap(find.byKey(const Key('kick-ban-reason-remove-Spam'))); + await tester.pumpAndSettle(); + expect(find.text('Spam'), findsNothing); + + await tester.tap(find.byKey(const Key('kick-ban-reasons-reset'))); + await tester.pumpAndSettle(); + expect(find.text('Spam'), findsOneWidget); + expect(find.text('Trolling'), findsNothing); + }); +} diff --git a/test/mirc_preset_parser_test.dart b/test/mirc_preset_parser_test.dart index b43375b..ea3770d 100644 --- a/test/mirc_preset_parser_test.dart +++ b/test/mirc_preset_parser_test.dart @@ -5,7 +5,9 @@ import 'package:flutter_test/flutter_test.dart'; void main() { test('decodes utf8 mirc preset base64', () { - final decoded = decodeMircPresetBase64(base64.encode(utf8.encode('line1\nline2'))); + final decoded = decodeMircPresetBase64( + base64.encode(utf8.encode('line1\nline2')), + ); expect(decoded, 'line1\nline2'); }); diff --git a/test/network_form_cert_import_test.dart b/test/network_form_cert_import_test.dart index 8f0f26e..e1bb39d 100644 --- a/test/network_form_cert_import_test.dart +++ b/test/network_form_cert_import_test.dart @@ -15,8 +15,7 @@ class _FakePicker implements DccFilePicker { const _cert = '-----BEGIN CERTIFICATE-----\nMIIByyCERT\n-----END CERTIFICATE-----'; -const _key = - '-----BEGIN PRIVATE KEY-----\nMIIEvKEY\n-----END PRIVATE KEY-----'; +const _key = '-----BEGIN PRIVATE KEY-----\nMIIEvKEY\n-----END PRIVATE KEY-----'; void main() { setUp(() => SharedPreferences.setMockInitialValues({})); @@ -78,10 +77,7 @@ void main() { await tester.tap(find.byKey(const Key('network-form-import-cert'))); await tester.pumpAndSettle(); - expect( - find.byKey(const Key('network-form-pkcs12-loaded')), - findsOneWidget, - ); + expect(find.byKey(const Key('network-form-pkcs12-loaded')), findsOneWidget); expect(find.text('PKCS#12 bundle loaded'), findsOneWidget); }); } diff --git a/test/notification_permission_settings_test.dart b/test/notification_permission_settings_test.dart index 277707a..182ce50 100644 --- a/test/notification_permission_settings_test.dart +++ b/test/notification_permission_settings_test.dart @@ -50,18 +50,20 @@ void main() { await tester.pumpAndSettle(); } - testWidgets('enabling notifications requests permission and enables on grant', - (tester) async { - final perms = _FakePermissions(notifResult: AppPermissionResult.granted); - await pump(tester, perms); + testWidgets( + 'enabling notifications requests permission and enables on grant', + (tester) async { + final perms = _FakePermissions(notifResult: AppPermissionResult.granted); + await pump(tester, perms); - await tester.tap(find.byKey(const Key('settings-notifications-enabled'))); - await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('settings-notifications-enabled'))); + await tester.pumpAndSettle(); - expect(perms.notifRequests, 1); - final saved = await SharedPrefsSettingsRepository().loadSettings(); - expect(saved.notificationsEnabled, isTrue); - }); + expect(perms.notifRequests, 1); + final saved = await SharedPrefsSettingsRepository().loadSettings(); + expect(saved.notificationsEnabled, isTrue); + }, + ); testWidgets('denied permission keeps notifications off', (tester) async { final perms = _FakePermissions(notifResult: AppPermissionResult.denied); @@ -79,11 +81,13 @@ void main() { ); }); - testWidgets('reconciles notifications off when OS permission is missing', - (tester) async { + testWidgets('reconciles notifications off when OS permission is missing', ( + tester, + ) async { // Stored as enabled, but the OS permission is not granted. - await SharedPrefsSettingsRepository() - .saveSettings(const AppSettings(notificationsEnabled: true)); + await SharedPrefsSettingsRepository().saveSettings( + const AppSettings(notificationsEnabled: true), + ); final perms = _FakePermissions(hasNotif: false); await tester.pumpWidget( @@ -123,5 +127,4 @@ void main() { saved = await SharedPrefsSettingsRepository().loadSettings(); expect(saved.analyticsConsent, isTrue); }); - } diff --git a/test/pem_bundle_test.dart b/test/pem_bundle_test.dart index 192824679c9d91513d3ac0304c3169834ede75ce..d3a6fd8abffb13bd607d392e7ccdf66630d0d09f 100644 GIT binary patch delta 10 RcmbQp)5|mA-o{7ztN<8a1Z)5R delta 15 WcmeC>naDHY9t)R(g2Kjo`m6vbI0V)J diff --git a/test/scram_sha256_session_test.dart b/test/scram_sha256_session_test.dart index 1c04b97..deb3aa9 100644 --- a/test/scram_sha256_session_test.dart +++ b/test/scram_sha256_session_test.dart @@ -139,10 +139,7 @@ void main() { session.createClientFinalMessage( 'r=clientNonceServer,s=c2FsdHlTYWx0,i=4096', ); - expect( - session.validateServerFinalMessage('e=invalid-proof'), - isFalse, - ); + expect(session.validateServerFinalMessage('e=invalid-proof'), isFalse); }); }); } diff --git a/test/server_preset_test.dart b/test/server_preset_test.dart index 6d9b962..9460ff8 100644 --- a/test/server_preset_test.dart +++ b/test/server_preset_test.dart @@ -29,8 +29,9 @@ void main() { }); test('preferredServer prefers a TLS server', () { - final libera = parseServerPresets(_sample) - .firstWhere((p) => p.networkName == 'Libera'); + final libera = parseServerPresets( + _sample, + ).firstWhere((p) => p.networkName == 'Libera'); expect(libera.preferredServer!.hostname, 'irc.libera.chat'); expect(libera.preferredServer!.useSsl, isTrue); expect(libera.preferredServer!.port, 6697); @@ -44,8 +45,9 @@ void main() { group('networkConfigFromPreset', () { test('maps the preferred server into a NetworkConfig', () { - final libera = parseServerPresets(_sample) - .firstWhere((p) => p.networkName == 'Libera'); + final libera = parseServerPresets( + _sample, + ).firstWhere((p) => p.networkName == 'Libera'); final config = networkConfigFromPreset(libera, id: 'net-1'); expect(config.name, 'Libera'); expect(config.host, 'irc.libera.chat'); @@ -55,10 +57,15 @@ void main() { }); test('maps a specific chosen server', () { - final libera = parseServerPresets(_sample) - .firstWhere((p) => p.networkName == 'Libera'); + final libera = parseServerPresets( + _sample, + ).firstWhere((p) => p.networkName == 'Libera'); final plain = libera.servers.firstWhere((s) => !s.useSsl); - final config = networkConfigFromPreset(libera, id: 'net-2', server: plain); + final config = networkConfigFromPreset( + libera, + id: 'net-2', + server: plain, + ); expect(config.host, 'plain.libera.chat'); expect(config.port, 6667); expect(config.useTls, isFalse); @@ -85,9 +92,7 @@ void main() { }); test('falls back to DBase on an empty directory', () async { - final service = ServerPresetService( - httpGet: (_) async => '{"data": []}', - ); + final service = ServerPresetService(httpGet: (_) async => '{"data": []}'); final presets = await service.fetchPresetsOrFallback(); expect(presets.single.networkName, 'DBase'); }); From d5c741c650dfd5dbe33ade1e6301b768e74bb466 Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 28 Aug 2026 11:18:19 +0200 Subject: [PATCH 10/11] Add writing options, keyboard shortcuts, diagnostics, and battery UX MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Composer autocorrect, keyboard suggestions, and sentence capitalization toggles in Settings → Writing - Hardware keyboard shortcuts: Ctrl+Tab / Alt+arrows switch tabs, Tab accepts the first nick-completion suggestion - Advanced section: app version row and copy-diagnostics action - Battery optimization exemption request in Settings → Permissions so background connections survive Doze --- lib/core/models/app_settings.dart | 22 + lib/core/platform/app_permissions.dart | 13 + .../chat/presentation/chat_screen.dart | 775 ++++++++++-------- .../presentation/settings_screen.dart | 121 +++ ...notification_permission_settings_test.dart | 13 + test/onboarding_permission_test.dart | 7 + test/widget_test.dart | 50 +- 7 files changed, 645 insertions(+), 356 deletions(-) diff --git a/lib/core/models/app_settings.dart b/lib/core/models/app_settings.dart index cde1918..5f04321 100644 --- a/lib/core/models/app_settings.dart +++ b/lib/core/models/app_settings.dart @@ -43,6 +43,9 @@ class AppSettings { this.nickDisplayFormat = NickDisplayFormat.plain, this.enterToSend = true, this.showSendButton = true, + this.composerAutocorrect = true, + this.composerSuggestions = true, + this.composerCapitalizeSentences = false, this.highlightWords = const [], this.autoAwayEnabled = false, this.autoAwayMinutes = 10, @@ -105,6 +108,11 @@ class AppSettings { final bool enterToSend; final bool showSendButton; + /// Composer keyboard behavior. + final bool composerAutocorrect; + final bool composerSuggestions; + final bool composerCapitalizeSentences; + /// Extra words (besides your nick) that trigger a highlight notification. final List highlightWords; final bool autoAwayEnabled; @@ -150,6 +158,9 @@ class AppSettings { NickDisplayFormat? nickDisplayFormat, bool? enterToSend, bool? showSendButton, + bool? composerAutocorrect, + bool? composerSuggestions, + bool? composerCapitalizeSentences, List? highlightWords, bool? autoAwayEnabled, int? autoAwayMinutes, @@ -195,6 +206,10 @@ class AppSettings { nickDisplayFormat: nickDisplayFormat ?? this.nickDisplayFormat, enterToSend: enterToSend ?? this.enterToSend, showSendButton: showSendButton ?? this.showSendButton, + composerAutocorrect: composerAutocorrect ?? this.composerAutocorrect, + composerSuggestions: composerSuggestions ?? this.composerSuggestions, + composerCapitalizeSentences: + composerCapitalizeSentences ?? this.composerCapitalizeSentences, highlightWords: highlightWords ?? this.highlightWords, autoAwayEnabled: autoAwayEnabled ?? this.autoAwayEnabled, autoAwayMinutes: autoAwayMinutes ?? this.autoAwayMinutes, @@ -237,6 +252,9 @@ class AppSettings { 'nickDisplayFormat': nickDisplayFormat.name, 'enterToSend': enterToSend, 'showSendButton': showSendButton, + 'composerAutocorrect': composerAutocorrect, + 'composerSuggestions': composerSuggestions, + 'composerCapitalizeSentences': composerCapitalizeSentences, 'highlightWords': highlightWords, 'autoAwayEnabled': autoAwayEnabled, 'autoAwayMinutes': autoAwayMinutes, @@ -315,6 +333,10 @@ class AppSettings { ), enterToSend: (json['enterToSend'] as bool?) ?? true, showSendButton: (json['showSendButton'] as bool?) ?? true, + composerAutocorrect: (json['composerAutocorrect'] as bool?) ?? true, + composerSuggestions: (json['composerSuggestions'] as bool?) ?? true, + composerCapitalizeSentences: + (json['composerCapitalizeSentences'] as bool?) ?? false, highlightWords: _stringList(json['highlightWords']), autoAwayEnabled: (json['autoAwayEnabled'] as bool?) ?? false, autoAwayMinutes: (json['autoAwayMinutes'] as num?)?.toInt() ?? 10, diff --git a/lib/core/platform/app_permissions.dart b/lib/core/platform/app_permissions.dart index eb749fe..3a10267 100644 --- a/lib/core/platform/app_permissions.dart +++ b/lib/core/platform/app_permissions.dart @@ -8,6 +8,11 @@ abstract class AppPermissions { Future requestNotifications(); Future hasNotifications(); + /// Asks Android to exempt the app from battery optimization so background + /// IRC connections are not killed by Doze. + Future requestIgnoreBatteryOptimizations(); + Future hasIgnoreBatteryOptimizations(); + /// Opens the OS app-settings page (used after a permanent denial). Future openSettingsPage(); } @@ -36,6 +41,14 @@ class PermissionHandlerAppPermissions implements AppPermissions { Future hasNotifications() async => (await Permission.notification.status).isGranted; + @override + Future requestIgnoreBatteryOptimizations() async => + _map(await Permission.ignoreBatteryOptimizations.request()); + + @override + Future hasIgnoreBatteryOptimizations() async => + (await Permission.ignoreBatteryOptimizations.status).isGranted; + @override Future openSettingsPage() async { await openAppSettings(); diff --git a/lib/features/chat/presentation/chat_screen.dart b/lib/features/chat/presentation/chat_screen.dart index d7709ff..478fb7c 100644 --- a/lib/features/chat/presentation/chat_screen.dart +++ b/lib/features/chat/presentation/chat_screen.dart @@ -173,393 +173,413 @@ class _ChatScreenState extends State { .where((message) => message.kind != IrcMessageKind.event) .toList(growable: false) : baseMessages; - return Scaffold( - appBar: AppBar( - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(_controller.activeTab.name), - Text( - _controller.activeTab.type == ChatTabType.channel && - _controller.activeChannelSummary.isNotEmpty - ? _controller.activeChannelSummary - : _controller.activeTab.type == ChatTabType.dcc && - _controller.activeDccSession != null - ? _dccSummary(_controller.activeDccSession!) - : _statusText(_controller.connection), - style: Theme.of(context).textTheme.bodySmall, - ), - ], - ), - actions: [ - if (_controller.activeTab.type == ChatTabType.channel) - Builder( - builder: (context) { - return IconButton( - onPressed: () => Scaffold.of(context).openEndDrawer(), - icon: const Icon(Icons.people_outline), - tooltip: 'Nick list', - ); - }, + return CallbackShortcuts( + bindings: _keyboardBindings(), + child: Scaffold( + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(_controller.activeTab.name), + Text( + _controller.activeTab.type == ChatTabType.channel && + _controller.activeChannelSummary.isNotEmpty + ? _controller.activeChannelSummary + : _controller.activeTab.type == ChatTabType.dcc && + _controller.activeDccSession != null + ? _dccSummary(_controller.activeDccSession!) + : _statusText(_controller.connection), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + actions: [ + if (_controller.activeTab.type == ChatTabType.channel) + Builder( + builder: (context) { + return IconButton( + onPressed: () => Scaffold.of(context).openEndDrawer(), + icon: const Icon(Icons.people_outline), + tooltip: 'Nick list', + ); + }, + ), + if (_controller.settings.showHeaderSearchButton) + IconButton( + onPressed: _toggleMessageSearch, + icon: Icon( + _messageSearchVisible ? Icons.search_off : Icons.search, + ), + tooltip: _messageSearchVisible + ? 'Close search' + : 'Search messages', + ), + IconButton( + onPressed: _openHistoryTools, + icon: const Icon(Icons.history), + tooltip: 'History tools', ), - if (_controller.settings.showHeaderSearchButton) IconButton( - onPressed: _toggleMessageSearch, - icon: Icon( - _messageSearchVisible ? Icons.search_off : Icons.search, - ), - tooltip: _messageSearchVisible - ? 'Close search' - : 'Search messages', + onPressed: _showJoinDialog, + icon: const Icon(Icons.tag), + tooltip: 'Join channel', ), - IconButton( - onPressed: _openHistoryTools, - icon: const Icon(Icons.history), - tooltip: 'History tools', - ), - IconButton( - onPressed: _showJoinDialog, - icon: const Icon(Icons.tag), - tooltip: 'Join channel', - ), - IconButton( - onPressed: _openChannelList, - icon: const Icon(Icons.format_list_bulleted), - tooltip: 'Channel list', - ), - if (_controller.dccSessions.isNotEmpty) IconButton( - key: const Key('chat-dcc-transfers'), - onPressed: _openDccTransfers, - icon: Badge.count( - count: _activeDccTransferCount, - isLabelVisible: _activeDccTransferCount > 0, - child: const Icon(Icons.swap_vert_circle_outlined), + onPressed: _openChannelList, + icon: const Icon(Icons.format_list_bulleted), + tooltip: 'Channel list', + ), + if (_controller.dccSessions.isNotEmpty) + IconButton( + key: const Key('chat-dcc-transfers'), + onPressed: _openDccTransfers, + icon: Badge.count( + count: _activeDccTransferCount, + isLabelVisible: _activeDccTransferCount > 0, + child: const Icon(Icons.swap_vert_circle_outlined), + ), + tooltip: 'DCC transfers', ), - tooltip: 'DCC transfers', + IconButton( + onPressed: _openSettings, + icon: const Icon(Icons.tune), + tooltip: 'Settings', ), - IconButton( - onPressed: _openSettings, - icon: const Icon(Icons.tune), - tooltip: 'Settings', - ), - IconButton( - onPressed: + IconButton( + onPressed: + _controller.connection.phase == + ConnectionPhase.connected + ? _controller.disconnect + : _controller.start, + icon: Icon( _controller.connection.phase == ConnectionPhase.connected - ? _controller.disconnect - : _controller.start, - icon: Icon( - _controller.connection.phase == ConnectionPhase.connected - ? Icons.link_off - : Icons.wifi_tethering, + ? Icons.link_off + : Icons.wifi_tethering, + ), + tooltip: + _controller.connection.phase == + ConnectionPhase.connected + ? 'Disconnect' + : 'Connect', ), - tooltip: - _controller.connection.phase == ConnectionPhase.connected - ? 'Disconnect' - : 'Connect', - ), - ], - ), - drawer: Drawer( - child: SafeArea( - child: ListView( - padding: EdgeInsets.zero, - children: [ - if (widget.onSwitchNetwork != null && - widget.networkController != null) ...[ - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 4), - child: Text( - 'NETWORKS', - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - fontWeight: FontWeight.bold, - letterSpacing: 0.8, - ), + ], + ), + drawer: Drawer( + child: SafeArea( + child: ListView( + padding: EdgeInsets.zero, + children: [ + if (widget.onSwitchNetwork != null && + widget.networkController != null) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 4), + child: Text( + 'NETWORKS', + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: 0.8, + ), + ), + ), + for (final network + in widget.networkController!.networks) + _buildNetworkSwitchTile(network), + ListTile( + leading: const Icon(Icons.dns_outlined), + title: const Text('Manage networks'), + subtitle: const Text('Add, edit, or browse servers'), + onTap: () { + Navigator.of(context).pop(); + widget.onManageNetworks?.call(); + }, + ), + const Divider(height: 1), + ], + ListTile( + title: Text(_controller.network.name), + subtitle: Text( + '${_controller.network.host}:${_controller.network.port}', ), ), - for (final network in widget.networkController!.networks) - _buildNetworkSwitchTile(network), + const Divider(height: 1), + for (final tab in _controller.tabs) + _buildTabTile(context, tab), + const Divider(height: 1), ListTile( - leading: const Icon(Icons.dns_outlined), - title: const Text('Manage networks'), - subtitle: const Text('Add, edit, or browse servers'), + leading: const Icon(Icons.block), + title: const Text('Ignore list'), onTap: () { Navigator.of(context).pop(); - widget.onManageNetworks?.call(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + IgnoreListScreen(controller: _controller), + ), + ); }, ), - const Divider(height: 1), - ], - ListTile( - title: Text(_controller.network.name), - subtitle: Text( - '${_controller.network.host}:${_controller.network.port}', - ), - ), - const Divider(height: 1), - for (final tab in _controller.tabs) - _buildTabTile(context, tab), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.block), - title: const Text('Ignore list'), - onTap: () { - Navigator.of(context).pop(); - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - IgnoreListScreen(controller: _controller), - ), - ); - }, - ), - ListTile( - leading: const Icon(Icons.rule_outlined), - title: const Text('User lists'), - subtitle: const Text( - 'Notify, protected, blacklist, auto-mode', + ListTile( + leading: const Icon(Icons.rule_outlined), + title: const Text('User lists'), + subtitle: const Text( + 'Notify, protected, blacklist, auto-mode', + ), + onTap: () { + Navigator.of(context).pop(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + UserListsScreen(controller: _controller), + ), + ); + }, ), - onTap: () { - Navigator.of(context).pop(); - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - UserListsScreen(controller: _controller), - ), - ); - }, - ), - ListTile( - leading: const Icon(Icons.info_outline), - title: const Text('Connection details'), - onTap: () { - Navigator.of(context).pop(); - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ConnectionDetailsScreen( - controller: _controller, + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('Connection details'), + onTap: () { + Navigator.of(context).pop(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ConnectionDetailsScreen( + controller: _controller, + ), ), - ), - ); - }, - ), - ], + ); + }, + ), + ], + ), ), ), - ), - endDrawer: _controller.activeTab.type == ChatTabType.channel - ? _buildNickListDrawer(context) - : null, - body: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - // In short viewports (landscape, or portrait with the keyboard - // open) the chrome around the message list (status/topic - // banners on top, composer + its bars on the bottom) can be - // taller than the available height and would crush the list to - // zero. When space is tight, cap the top/bottom chrome and let - // each scroll internally so the message list always keeps room - // and nothing overflows. In normal viewports the caps are the - // full height (a no-op), so interactive banners such as the DCC - // accept/decline actions stay at their natural, hittable size. - final tight = constraints.maxHeight < 380; - final topChromeMaxHeight = tight - ? constraints.maxHeight * 0.35 - : constraints.maxHeight; - final bottomClusterMaxHeight = tight - ? constraints.maxHeight * 0.5 - : constraints.maxHeight; - return Column( - children: [ - ConstrainedBox( - constraints: BoxConstraints( - maxHeight: topChromeMaxHeight, - ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _ConnectionBanner( - controller: _controller, - network: _controller.network, - connectedBannerDismissed: - _connectedBannerDismissed, - onDismissConnectedBanner: () => setState( - () => _connectedBannerDismissed = true, - ), - ), - if (_messageSearchVisible) - _InlineMessageSearchBar( - controller: _messageSearchController, - filter: _messageSearchFilter, - resultCount: visibleMessages.length, - onFilterChanged: (filter) => setState( - () => _messageSearchFilter = filter, + endDrawer: _controller.activeTab.type == ChatTabType.channel + ? _buildNickListDrawer(context) + : null, + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + // In short viewports (landscape, or portrait with the keyboard + // open) the chrome around the message list (status/topic + // banners on top, composer + its bars on the bottom) can be + // taller than the available height and would crush the list to + // zero. When space is tight, cap the top/bottom chrome and let + // each scroll internally so the message list always keeps room + // and nothing overflows. In normal viewports the caps are the + // full height (a no-op), so interactive banners such as the DCC + // accept/decline actions stay at their natural, hittable size. + final tight = constraints.maxHeight < 380; + final topChromeMaxHeight = tight + ? constraints.maxHeight * 0.35 + : constraints.maxHeight; + final bottomClusterMaxHeight = tight + ? constraints.maxHeight * 0.5 + : constraints.maxHeight; + return Column( + children: [ + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: topChromeMaxHeight, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _ConnectionBanner( + controller: _controller, + network: _controller.network, + connectedBannerDismissed: + _connectedBannerDismissed, + onDismissConnectedBanner: () => setState( + () => _connectedBannerDismissed = true, ), - onChanged: (_) => setState(() {}), - onClose: _toggleMessageSearch, - ), - if ((_controller.activeChannelTopic ?? '') - .trim() - .isNotEmpty) - _ChannelTopicBar( - topic: _controller.activeChannelTopic!.trim(), ), - if (_controller.activeTab.type == - ChatTabType.dcc && - _controller.activeDccSession != null) - _DccSessionBanner( - session: _controller.activeDccSession!, - onAccept: _controller.acceptActiveDccSession, - onDecline: - _controller.declineActiveDccSession, - onClose: _controller.closeActiveDccSession, - ), - if (_controller.activeTab.type == - ChatTabType.server) - _ServiceQuickActions( - onRun: (service, command) async { - await _controller.sendServiceShortcut( - service, - command, - ); - }, - ), - ], + if (_messageSearchVisible) + _InlineMessageSearchBar( + controller: _messageSearchController, + filter: _messageSearchFilter, + resultCount: visibleMessages.length, + onFilterChanged: (filter) => setState( + () => _messageSearchFilter = filter, + ), + onChanged: (_) => setState(() {}), + onClose: _toggleMessageSearch, + ), + if ((_controller.activeChannelTopic ?? '') + .trim() + .isNotEmpty) + _ChannelTopicBar( + topic: _controller.activeChannelTopic! + .trim(), + ), + if (_controller.activeTab.type == + ChatTabType.dcc && + _controller.activeDccSession != null) + _DccSessionBanner( + session: _controller.activeDccSession!, + onAccept: + _controller.acceptActiveDccSession, + onDecline: + _controller.declineActiveDccSession, + onClose: _controller.closeActiveDccSession, + ), + if (_controller.activeTab.type == + ChatTabType.server) + _ServiceQuickActions( + onRun: (service, command) async { + await _controller.sendServiceShortcut( + service, + command, + ); + }, + ), + ], + ), ), ), - ), - Expanded( - child: _MessageList( - messages: visibleMessages, - knownNicks: { - ..._controller.activeChannelUsers, - _controller.currentNick, - }, - channelPrefixes: _controller.channelPrefixChars, - nickPrefixes: _controller.nickPrefixChars, - showAttachmentPreviews: - _controller.settings.showAttachmentPreviews, - resolveReplyTarget: (replyId) => _controller - .messageByMsgId(_controller.activeTabId, replyId), - resolveReactions: _controller.reactionsForMessage, - onReactToMessage: _controller.reactToMessage, - onRedactMessage: _controller.redactMessage, - onQuoteMessage: (message) => _insertIntoComposer( - '> ${stripIrcFormatting(message.content)}', - ), - onReplyWithNick: (message) { - final prefix = - message.sender == _controller.currentNick - ? '' - : '${message.sender}: '; - _insertIntoComposer(prefix); - }, - onReplyToMessage: _setPendingReply, - onDownloadAttachment: _downloadAttachment, - onNickTap: (nick) => unawaited( - _controller.performChannelUserAction( - nick, - ChannelUserAction.query, + Expanded( + child: _MessageList( + messages: visibleMessages, + knownNicks: { + ..._controller.activeChannelUsers, + _controller.currentNick, + }, + channelPrefixes: _controller.channelPrefixChars, + nickPrefixes: _controller.nickPrefixChars, + showAttachmentPreviews: + _controller.settings.showAttachmentPreviews, + resolveReplyTarget: (replyId) => + _controller.messageByMsgId( + _controller.activeTabId, + replyId, + ), + resolveReactions: _controller.reactionsForMessage, + onReactToMessage: _controller.reactToMessage, + onRedactMessage: _controller.redactMessage, + onQuoteMessage: (message) => _insertIntoComposer( + '> ${stripIrcFormatting(message.content)}', ), - ), - onNickLongPress: (nick) => - unawaited(_showChannelUserActions(nick)), - onChannelTap: (channel) => unawaited( - _controller.joinChannel( - JoinChannelRequest(channel: channel), + onReplyWithNick: (message) { + final prefix = + message.sender == _controller.currentNick + ? '' + : '${message.sender}: '; + _insertIntoComposer(prefix); + }, + onReplyToMessage: _setPendingReply, + onDownloadAttachment: _downloadAttachment, + onNickTap: (nick) => unawaited( + _controller.performChannelUserAction( + nick, + ChannelUserAction.query, + ), + ), + onNickLongPress: (nick) => + unawaited(_showChannelUserActions(nick)), + onChannelTap: (channel) => unawaited( + _controller.joinChannel( + JoinChannelRequest(channel: channel), + ), ), + showTimestamps: _controller.settings.showTimestamps, + timestampFormat: + _controller.settings.timestampFormat, + timestampPosition: + _controller.settings.timestampPosition, + nickDisplayFormat: + _controller.settings.nickDisplayFormat, + onLoadOlder: + _controller.hasPersistentHistory && + !_messageSearchVisible + ? () async { + await _controller.loadOlderHistory( + _controller.activeTabId, + ); + } + : null, ), - showTimestamps: _controller.settings.showTimestamps, - timestampFormat: _controller.settings.timestampFormat, - timestampPosition: - _controller.settings.timestampPosition, - nickDisplayFormat: - _controller.settings.nickDisplayFormat, - onLoadOlder: - _controller.hasPersistentHistory && - !_messageSearchVisible - ? () async { - await _controller.loadOlderHistory( - _controller.activeTabId, - ); - } - : null, - ), - ), - ConstrainedBox( - constraints: BoxConstraints( - maxHeight: bottomClusterMaxHeight, ), - child: SingleChildScrollView( - reverse: true, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (_controller.commandHistory.isNotEmpty) - _CommandHistoryBar( - entries: _controller.commandHistory, - onSelect: (value) => setState( - () => _composerController.text = value, + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: bottomClusterMaxHeight, + ), + child: SingleChildScrollView( + reverse: true, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_controller.commandHistory.isNotEmpty) + _CommandHistoryBar( + entries: _controller.commandHistory, + onSelect: (value) => setState( + () => _composerController.text = value, + ), ), - ), - if (_controller.activeTypingUsers.isNotEmpty) - _TypingIndicator( - users: _controller.activeTypingUsers, - ), - if (_pendingReplyMessage != null) - _PendingReplyBar( - message: _pendingReplyMessage!, - onCancel: () => setState( - () => _pendingReplyMessage = null, + if (_controller.activeTypingUsers.isNotEmpty) + _TypingIndicator( + users: _controller.activeTypingUsers, + ), + if (_pendingReplyMessage != null) + _PendingReplyBar( + message: _pendingReplyMessage!, + onCancel: () => setState( + () => _pendingReplyMessage = null, + ), + ), + const Divider(height: 1), + _ComposerArea( + suggestions: _composerSuggestions, + autocompleteSuggestions: + _autocompleteSuggestions, + controller: _composerController, + hintText: + _controller.activeTab.type == + ChatTabType.server + ? 'Type raw IRC or /join #channel' + : _controller.activeTab.type == + ChatTabType.dcc + ? (_controller.activeDccSession?.type == + DccSessionType.chat + ? 'Type DCC chat message' + : 'DCC SEND tabs do not accept messages') + : 'Message ${_controller.activeTab.name}', + onChanged: _handleComposerChanged, + onSubmitted: _submit, + canSendDccFile: + _controller.activeTab.type == + ChatTabType.query, + onPickDccFile: _pickAndSendDccFile, + onSuggestionSelected: + _applyComposerSuggestion, + onAutocompleteSelected: + _applyAutocompleteSuggestion, + enterToSend: _controller.settings.enterToSend, + showSendButton: + _controller.settings.showSendButton, + autocorrect: + _controller.settings.composerAutocorrect, + enableSuggestions: + _controller.settings.composerSuggestions, + capitalizeSentences: _controller + .settings + .composerCapitalizeSentences, + onCameraPhoto: () => + _captureAndSendMedia(ImageSource.camera), + onGalleryImage: () => + _captureAndSendMedia(ImageSource.gallery), + onCameraVideo: () => _captureAndSendMedia( + ImageSource.camera, + video: true, ), ), - const Divider(height: 1), - _ComposerArea( - suggestions: _composerSuggestions, - autocompleteSuggestions: - _autocompleteSuggestions, - controller: _composerController, - hintText: - _controller.activeTab.type == - ChatTabType.server - ? 'Type raw IRC or /join #channel' - : _controller.activeTab.type == - ChatTabType.dcc - ? (_controller.activeDccSession?.type == - DccSessionType.chat - ? 'Type DCC chat message' - : 'DCC SEND tabs do not accept messages') - : 'Message ${_controller.activeTab.name}', - onChanged: _handleComposerChanged, - onSubmitted: _submit, - canSendDccFile: - _controller.activeTab.type == - ChatTabType.query, - onPickDccFile: _pickAndSendDccFile, - onSuggestionSelected: _applyComposerSuggestion, - onAutocompleteSelected: - _applyAutocompleteSuggestion, - enterToSend: _controller.settings.enterToSend, - showSendButton: - _controller.settings.showSendButton, - onCameraPhoto: () => - _captureAndSendMedia(ImageSource.camera), - onGalleryImage: () => - _captureAndSendMedia(ImageSource.gallery), - onCameraVideo: () => _captureAndSendMedia( - ImageSource.camera, - video: true, - ), - ), - ], + ], + ), ), ), - ), - ], - ); - }, + ], + ); + }, + ), ), ), ); @@ -568,6 +588,40 @@ class _ChatScreenState extends State { ); } + /// Hardware keyboard shortcuts: Ctrl+Tab / Alt+arrows switch tabs, and Tab + /// accepts the first nick-completion suggestion while the panel is open. + Map _keyboardBindings() { + return { + const SingleActivator(LogicalKeyboardKey.tab, control: true): () => + _selectAdjacentTab(1), + const SingleActivator( + LogicalKeyboardKey.tab, + control: true, + shift: true, + ): () => + _selectAdjacentTab(-1), + const SingleActivator(LogicalKeyboardKey.arrowDown, alt: true): () => + _selectAdjacentTab(1), + const SingleActivator(LogicalKeyboardKey.arrowUp, alt: true): () => + _selectAdjacentTab(-1), + if (_autocompleteSuggestions.isNotEmpty) + const SingleActivator(LogicalKeyboardKey.tab): () => + _applyAutocompleteSuggestion(_autocompleteSuggestions.first), + }; + } + + void _selectAdjacentTab(int delta) { + final tabs = _controller.tabs; + if (tabs.length < 2) { + return; + } + final currentIndex = tabs.indexWhere( + (tab) => tab.id == _controller.activeTabId, + ); + final nextIndex = (currentIndex + delta + tabs.length) % tabs.length; + _controller.selectTab(tabs[nextIndex].id); + } + Future _showJoinDialog() async { final result = await showDialog( context: context, @@ -1681,6 +1735,9 @@ class _ComposerArea extends StatelessWidget { required this.onAutocompleteSelected, required this.enterToSend, required this.showSendButton, + this.autocorrect = true, + this.enableSuggestions = true, + this.capitalizeSentences = false, required this.onCameraPhoto, required this.onGalleryImage, required this.onCameraVideo, @@ -1698,6 +1755,9 @@ class _ComposerArea extends StatelessWidget { final ValueChanged onAutocompleteSelected; final bool enterToSend; final bool showSendButton; + final bool autocorrect; + final bool enableSuggestions; + final bool capitalizeSentences; final VoidCallback onCameraPhoto; final VoidCallback onGalleryImage; final VoidCallback onCameraVideo; @@ -1731,6 +1791,11 @@ class _ComposerArea extends StatelessWidget { textInputAction: enterToSend ? TextInputAction.send : TextInputAction.newline, + autocorrect: autocorrect, + enableSuggestions: enableSuggestions, + textCapitalization: capitalizeSentences + ? TextCapitalization.sentences + : TextCapitalization.none, onChanged: onChanged, onSubmitted: enterToSend ? (_) => onSubmitted() : null, decoration: InputDecoration(hintText: hintText), diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index ef70af9..ed8f35f 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:androidircx/app/theme/app_theme.dart'; +import 'package:androidircx/core/app/app_version.dart'; import 'package:androidircx/core/models/app_settings.dart'; import 'package:androidircx/core/platform/app_permissions.dart'; import 'package:androidircx/core/presets/server_preset_service.dart'; @@ -22,6 +23,7 @@ import 'package:androidircx/features/settings/presentation/theme_editor_screen.d import 'package:androidircx/monetization/monetization_config.dart'; import 'package:androidircx/monetization/monetization_controller.dart'; import 'package:androidircx/monetization/monetization_scope.dart'; +import 'package:flutter/foundation.dart' show defaultTargetPlatform; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:local_auth/local_auth.dart'; @@ -82,6 +84,7 @@ class _SettingsScreenState extends State { bool? _lastHasNoAds; bool _isLoading = true; bool _didResolveController = false; + bool _hasBatteryExemption = false; AppPermissions get _permissions => widget.permissions ?? const PermissionHandlerAppPermissions(); @@ -630,6 +633,24 @@ class _SettingsScreenState extends State { _settings.copyWith(analyticsConsent: value), ), ), + const Divider(height: 1), + ListTile( + key: const Key('settings-battery-optimization'), + leading: const Icon(Icons.battery_saver_outlined), + title: const Text('Battery optimization'), + subtitle: Text( + _hasBatteryExemption + ? 'Exempted — background connections stay alive.' + : 'Ask Android to keep IRC connections alive in ' + 'the background.', + ), + trailing: _hasBatteryExemption + ? const Icon(Icons.check_circle_outline) + : null, + onTap: _hasBatteryExemption + ? null + : () => unawaited(_requestBatteryExemption()), + ), ], ), const SizedBox(height: 12), @@ -683,6 +704,41 @@ class _SettingsScreenState extends State { ), ), const Divider(height: 1), + SwitchListTile( + key: const Key('settings-composer-autocorrect'), + title: const Text('Autocorrect'), + subtitle: const Text( + 'Let the keyboard auto-correct while typing.', + ), + value: _settings.composerAutocorrect, + onChanged: (value) => _saveSettings( + _settings.copyWith(composerAutocorrect: value), + ), + ), + const Divider(height: 1), + SwitchListTile( + key: const Key('settings-composer-suggestions'), + title: const Text('Keyboard suggestions'), + subtitle: const Text( + 'Show the keyboard suggestion strip.', + ), + value: _settings.composerSuggestions, + onChanged: (value) => _saveSettings( + _settings.copyWith(composerSuggestions: value), + ), + ), + const Divider(height: 1), + SwitchListTile( + key: const Key('settings-composer-capitalize'), + title: const Text('Capitalize sentences'), + value: _settings.composerCapitalizeSentences, + onChanged: (value) => _saveSettings( + _settings.copyWith( + composerCapitalizeSentences: value, + ), + ), + ), + const Divider(height: 1), ListTile( key: const Key('settings-command-aliases'), leading: const Icon(Icons.bolt_outlined), @@ -856,6 +912,29 @@ class _SettingsScreenState extends State { ], ), const SizedBox(height: 12), + _SettingsSection( + title: 'Advanced', + children: [ + const ListTile( + key: Key('settings-app-version'), + leading: Icon(Icons.info_outline), + title: Text('App version'), + subtitle: Text('AndroidIRCX Flutter v$appVersion'), + ), + const Divider(height: 1), + ListTile( + key: const Key('settings-copy-diagnostics'), + leading: const Icon(Icons.bug_report_outlined), + title: const Text('Copy diagnostics'), + subtitle: const Text( + 'Copy version and display settings for bug reports. ' + 'Contains no passwords or chat content.', + ), + onTap: () => unawaited(_copyDiagnostics()), + ), + ], + ), + const SizedBox(height: 12), if (monetizationScope != null && monetizationScope.controller.hasNoAds) ..._premiumAdsSettingsSection(monetizationScope), @@ -1072,14 +1151,56 @@ class _SettingsScreenState extends State { /// on while the OS permission is granted. Future _refreshPermissionStatuses() async { final hasNotifications = await _permissions.hasNotifications(); + final hasBatteryExemption = await _permissions + .hasIgnoreBatteryOptimizations(); if (!mounted) { return; } + setState(() => _hasBatteryExemption = hasBatteryExemption); if (_settings.notificationsEnabled && !hasNotifications) { await _saveSettings(_settings.copyWith(notificationsEnabled: false)); } } + Future _copyDiagnostics() async { + final diagnostics = [ + 'AndroidIRCX Flutter v$appVersion', + 'Platform: $defaultTargetPlatform', + 'Theme: ${_settings.themePreset.name}', + 'Density: ${_settings.messageDensity.name}', + 'Font: ${_settings.messageFontFamily} ' + '(${(_settings.messageFontScale * 100).round()}%)', + 'Timestamps: ${_settings.showTimestamps} ' + '(${_settings.timestampFormat}, ' + '${_settings.timestampPosition.name})', + 'Nick style: ${_settings.nickDisplayFormat.name}', + 'Notifications: ${_settings.notificationsEnabled}', + 'Battery exemption: $_hasBatteryExemption', + 'History retention/tab: ${_settings.historyRetentionPerTab}', + ].join('\n'); + await Clipboard.setData(ClipboardData(text: diagnostics)); + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Diagnostics copied to clipboard.')), + ); + } + + Future _requestBatteryExemption() async { + final result = await _permissions.requestIgnoreBatteryOptimizations(); + if (!mounted) { + return; + } + if (result == AppPermissionResult.granted) { + setState(() => _hasBatteryExemption = true); + return; + } + if (result == AppPermissionResult.permanentlyDenied) { + await _permissions.openSettingsPage(); + } + } + Future _toggleNotifications(bool value) async { if (!value) { await _saveSettings(_settings.copyWith(notificationsEnabled: false)); diff --git a/test/notification_permission_settings_test.dart b/test/notification_permission_settings_test.dart index 182ce50..2c1e7c4 100644 --- a/test/notification_permission_settings_test.dart +++ b/test/notification_permission_settings_test.dart @@ -26,6 +26,19 @@ class _FakePermissions implements AppPermissions { return notifResult; } + bool hasBattery = false; + int batteryRequests = 0; + + @override + Future hasIgnoreBatteryOptimizations() async => hasBattery; + + @override + Future requestIgnoreBatteryOptimizations() async { + batteryRequests++; + hasBattery = true; + return AppPermissionResult.granted; + } + @override Future openSettingsPage() async {} } diff --git a/test/onboarding_permission_test.dart b/test/onboarding_permission_test.dart index 75e257b..9c719db 100644 --- a/test/onboarding_permission_test.dart +++ b/test/onboarding_permission_test.dart @@ -20,6 +20,13 @@ class _FakePermissions implements AppPermissions { @override Future hasNotifications() async => false; + @override + Future hasIgnoreBatteryOptimizations() async => false; + + @override + Future requestIgnoreBatteryOptimizations() async => + AppPermissionResult.granted; + @override Future openSettingsPage() async {} } diff --git a/test/widget_test.dart b/test/widget_test.dart index 0bbb809..26b8c82 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:typed_data'; import 'package:androidircx/app/app.dart'; import 'package:androidircx/core/models/app_settings.dart'; @@ -33,6 +32,7 @@ import 'package:androidircx/irc/services/irc_service.dart'; import 'package:androidircx/irc/services/irc_transport.dart'; import 'package:androidircx/media/services/media_download_service.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -1586,6 +1586,54 @@ void main() { controller.dispose(); }); + testWidgets('switches tabs with hardware keyboard shortcuts', (tester) async { + SharedPreferences.setMockInitialValues({}); + const network = NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.dbase.in.rs', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + ); + final transport = _FakeTransport(); + final controller = ChatSessionController( + network: network, + ircService: IrcService(transportConnector: (_) async => transport), + ); + + await tester.pumpWidget( + MaterialApp(home: ChatScreen(controller: controller)), + ); + await tester.pump(); + await controller.joinChannel(const JoinChannelRequest(channel: '#room')); + await tester.pump(); + + // Joining focuses the channel tab; the server tab is the other one. + final startTab = controller.activeTab.name; + expect(startTab, '#room'); + + // Focus the composer so key events land inside the shortcut scope. + await tester.tap(find.byType(TextField).first); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + expect(controller.activeTab.name, isNot('#room')); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await tester.pump(); + + expect(controller.activeTab.name, '#room'); + + controller.dispose(); + }); + testWidgets('lists dcc sessions in the transfers modal', (tester) async { SharedPreferences.setMockInitialValues({}); const network = NetworkConfig( From e975c4458e0519164aeb4bab76312cac760f7e79 Mon Sep 17 00:00:00 2001 From: Velimir Majstorov Date: Fri, 28 Aug 2026 11:28:59 +0200 Subject: [PATCH 11/11] Add per-channel notification rules and channel vibration/LED MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Per-channel/query override in channel settings: default, all messages, mentions only, or muted — applied to both notifications and event sounds, persisted per network - "All messages" promotes regular channel chat to a highlight notification; "muted" silences even highlights - Android highlight/query/error notification channels now enable vibration and the notification LED at creation; Settings → Notifications links to the system channel configuration - Bump version to 1.0.11+15 --- .../flutter/AndroidIrcxForegroundService.kt | 14 +++ lib/core/app/app_version.dart | 4 +- .../application/chat_session_controller.dart | 62 ++++++++++++- ...channel_notification_rules_repository.dart | 93 +++++++++++++++++++ .../presentation/channel_settings_screen.dart | 32 +++++++ .../presentation/settings_screen.dart | 11 +++ pubspec.yaml | 2 +- test/chat_session_controller_test.dart | 83 +++++++++++++++++ 8 files changed, 297 insertions(+), 4 deletions(-) create mode 100644 lib/features/chat/data/channel_notification_rules_repository.dart diff --git a/android/app/src/main/kotlin/com/androidircx/flutter/AndroidIrcxForegroundService.kt b/android/app/src/main/kotlin/com/androidircx/flutter/AndroidIrcxForegroundService.kt index 6eadd11..bdb7ccb 100644 --- a/android/app/src/main/kotlin/com/androidircx/flutter/AndroidIrcxForegroundService.kt +++ b/android/app/src/main/kotlin/com/androidircx/flutter/AndroidIrcxForegroundService.kt @@ -9,6 +9,7 @@ import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo import android.graphics.BitmapFactory +import android.graphics.Color import android.os.Build import android.os.IBinder @@ -219,12 +220,14 @@ class AndroidIrcxForegroundService : Service() { context.getString(R.string.notification_channel_highlights), context.getString(R.string.notification_channel_highlights_description), NotificationManager.IMPORTANCE_DEFAULT, + alerting = true, ), channel( CHANNEL_QUERIES, context.getString(R.string.notification_channel_queries), context.getString(R.string.notification_channel_queries_description), NotificationManager.IMPORTANCE_DEFAULT, + alerting = true, ), channel( CHANNEL_DCC_TRANSFERS, @@ -243,6 +246,7 @@ class AndroidIrcxForegroundService : Service() { context.getString(R.string.notification_channel_errors), context.getString(R.string.notification_channel_errors_description), NotificationManager.IMPORTANCE_HIGH, + alerting = true, ), ), ) @@ -253,9 +257,19 @@ class AndroidIrcxForegroundService : Service() { name: String, description: String, importance: Int, + alerting: Boolean = false, ): NotificationChannel { return NotificationChannel(id, name, importance).apply { this.description = description + if (alerting) { + // Vibration + notification LED for user-facing alerts. + // Users can still tune these per channel in system + // settings; existing installs keep their prior channel + // configuration (Android only applies this at creation). + enableVibration(true) + enableLights(true) + lightColor = Color.GREEN + } } } diff --git a/lib/core/app/app_version.dart b/lib/core/app/app_version.dart index 795c781..08efebc 100644 --- a/lib/core/app/app_version.dart +++ b/lib/core/app/app_version.dart @@ -1,5 +1,5 @@ -const appVersionName = '1.0.10'; -const appVersionCode = 14; +const appVersionName = '1.0.11'; +const appVersionCode = 15; const appVersion = '$appVersionName+$appVersionCode'; const ctcpVersionReply = 'AndroidIRCX Flutter v$appVersion'; diff --git a/lib/features/chat/application/chat_session_controller.dart b/lib/features/chat/application/chat_session_controller.dart index b08ebab..ec9068f 100644 --- a/lib/features/chat/application/chat_session_controller.dart +++ b/lib/features/chat/application/chat_session_controller.dart @@ -15,6 +15,7 @@ import 'package:androidircx/features/chat/application/command_service.dart'; import 'package:androidircx/features/chat/application/message_history_formatter.dart'; import 'package:androidircx/core/storage/settings_repository.dart'; import 'package:androidircx/core/storage/shared_prefs_settings_repository.dart'; +import 'package:androidircx/features/chat/data/channel_notification_rules_repository.dart'; import 'package:androidircx/features/chat/data/chat_session_persistence.dart'; import 'package:androidircx/features/chat/data/message_history_repository.dart'; import 'package:androidircx/features/chat/data/user_list_entry.dart'; @@ -201,6 +202,7 @@ class ChatSessionController extends ChangeNotifier { CommandService? commandService, UserListsRepository? userListsRepository, SoundService? soundService, + ChannelNotificationRulesRepository? channelNotificationRulesRepository, int maxReconnectAttempts = 6, Duration reconnectBaseDelay = const Duration(seconds: 2), Duration reconnectMaxDelay = const Duration(seconds: 60), @@ -219,6 +221,9 @@ class ChatSessionController extends ChangeNotifier { _commandService = commandService ?? CommandService(), _userListsRepository = userListsRepository, _soundService = soundService, + _channelNotificationRulesRepository = + channelNotificationRulesRepository ?? + ChannelNotificationRulesRepository(), _maxReconnectAttempts = maxReconnectAttempts, _reconnectBaseDelay = reconnectBaseDelay, _reconnectMaxDelay = reconnectMaxDelay, @@ -245,6 +250,10 @@ class ChatSessionController extends ChangeNotifier { final CommandService _commandService; final UserListsRepository? _userListsRepository; final SoundService? _soundService; + final ChannelNotificationRulesRepository _channelNotificationRulesRepository; + + /// Per-tab notification overrides keyed by lower-case target name. + final Map _channelNotificationRules = {}; /// Last connection phase a sound was played for, so repeated snapshots in /// the same phase (or reconnect retries) do not re-trigger sounds. @@ -1105,6 +1114,7 @@ class ChatSessionController extends ChangeNotifier { await _commandService.load(); await _loadPersistedState(); await _loadAutoModeEntries(); + await _loadChannelNotificationRules(); if (_isDisposed) { return; } @@ -6544,6 +6554,35 @@ class ChatSessionController extends ChangeNotifier { return appended; } + /// Per-tab notification override for [target] (channel or query name). + ChannelNotificationRule notificationRuleFor(String target) { + return _channelNotificationRules[target.toLowerCase()] ?? + ChannelNotificationRule.defaults; + } + + Future setChannelNotificationRule( + String target, + ChannelNotificationRule rule, + ) async { + final key = target.toLowerCase(); + if (rule == ChannelNotificationRule.defaults) { + _channelNotificationRules.remove(key); + } else { + _channelNotificationRules[key] = rule; + } + notifyListeners(); + await _channelNotificationRulesRepository.setRule(network.id, target, rule); + } + + Future _loadChannelNotificationRules() async { + final rules = await _channelNotificationRulesRepository.loadRules( + network.id, + ); + _channelNotificationRules + ..clear() + ..addAll(rules); + } + void _emitIncomingMessageNotification(IrcMessage? message) { if (message == null || message.isOwn || message.isPlayback) { return; @@ -6558,10 +6597,31 @@ class ChatSessionController extends ChangeNotifier { return; } - final channelKind = _notificationKindForMessage(message, tab); + final rule = + (tab.type == ChatTabType.channel || tab.type == ChatTabType.query) + ? notificationRuleFor(tab.name) + : ChannelNotificationRule.defaults; + if (rule == ChannelNotificationRule.mute) { + return; + } + + var channelKind = _notificationKindForMessage(message, tab); + // "All messages" promotes regular channel chat to a highlight-channel + // notification; by default channels only notify on highlights/media. + if (channelKind == null && + rule == ChannelNotificationRule.all && + tab.type == ChatTabType.channel && + (message.kind == IrcMessageKind.chat || + message.kind == IrcMessageKind.action)) { + channelKind = ForegroundNotificationChannelKind.highlights; + } if (channelKind == null) { return; } + if (rule == ChannelNotificationRule.mentionsOnly && + channelKind != ForegroundNotificationChannelKind.highlights) { + return; + } // Sounds are independent of the notification permission gating below. _playSound(switch (channelKind) { diff --git a/lib/features/chat/data/channel_notification_rules_repository.dart b/lib/features/chat/data/channel_notification_rules_repository.dart new file mode 100644 index 0000000..95cfd5a --- /dev/null +++ b/lib/features/chat/data/channel_notification_rules_repository.dart @@ -0,0 +1,93 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +/// Per-channel/query notification override. +enum ChannelNotificationRule { + /// Follow the global notification settings (highlights, PMs, ...). + defaults, + + /// Notify for every message in this tab, not just highlights. + all, + + /// Only notify when the message is a highlight/mention. + mentionsOnly, + + /// Never notify (and never play a sound) for this tab. + mute, +} + +String channelNotificationRuleLabel(ChannelNotificationRule rule) { + return switch (rule) { + ChannelNotificationRule.defaults => 'Default', + ChannelNotificationRule.all => 'All messages', + ChannelNotificationRule.mentionsOnly => 'Mentions only', + ChannelNotificationRule.mute => 'Muted', + }; +} + +/// Persists per-channel notification rules keyed by network + target name. +class ChannelNotificationRulesRepository { + static const _key = 'androidircx.channelNotificationRules'; + + static String _entryKey(String networkId, String target) => + '$networkId|${target.toLowerCase()}'; + + Future> loadRules( + String networkId, + ) async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_key); + if (raw == null || raw.isEmpty) { + return const {}; + } + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) { + return const {}; + } + final prefix = '$networkId|'; + final rules = {}; + decoded.forEach((key, value) { + if (key is! String || value is! String || !key.startsWith(prefix)) { + return; + } + for (final rule in ChannelNotificationRule.values) { + if (rule.name == value) { + rules[key.substring(prefix.length)] = rule; + } + } + }); + return rules; + } catch (_) { + return const {}; + } + } + + Future setRule( + String networkId, + String target, + ChannelNotificationRule rule, + ) async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_key); + Map stored = {}; + if (raw != null && raw.isNotEmpty) { + try { + final decoded = jsonDecode(raw); + if (decoded is Map) { + stored = Map.from(decoded); + } + } catch (_) { + // Corrupt storage: start over. + } + } + final entryKey = _entryKey(networkId, target); + if (rule == ChannelNotificationRule.defaults) { + stored.remove(entryKey); + } else { + stored[entryKey] = rule.name; + } + await prefs.setString(_key, jsonEncode(stored)); + } +} diff --git a/lib/features/chat/presentation/channel_settings_screen.dart b/lib/features/chat/presentation/channel_settings_screen.dart index 994e75c..6ce2ce8 100644 --- a/lib/features/chat/presentation/channel_settings_screen.dart +++ b/lib/features/chat/presentation/channel_settings_screen.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:androidircx/core/models/chat_tab.dart'; import 'package:androidircx/core/models/irc_message.dart'; import 'package:androidircx/features/chat/application/chat_session_controller.dart'; +import 'package:androidircx/features/chat/data/channel_notification_rules_repository.dart'; import 'package:androidircx/features/chat/data/channel_notes_repository.dart'; import 'package:androidircx/features/connections/application/network_list_controller.dart'; import 'package:androidircx/irc/parser/irc_formatter.dart'; @@ -130,6 +131,37 @@ class _ChannelSettingsScreenState extends State { value: _autoJoin, onChanged: (value) => unawaited(_toggleAutoJoin(value)), ), + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Notifications'), + subtitle: const Text( + 'Override the global notification rules for this channel.', + ), + trailing: DropdownButton( + key: const Key('channel-settings-notification-rule'), + value: widget.controller.notificationRuleFor(widget.tab.name), + onChanged: (value) { + if (value == null) { + return; + } + unawaited( + widget.controller.setChannelNotificationRule( + widget.tab.name, + value, + ), + ); + setState(() {}); + }, + items: ChannelNotificationRule.values + .map( + (rule) => DropdownMenuItem( + value: rule, + child: Text(channelNotificationRuleLabel(rule)), + ), + ) + .toList(growable: false), + ), + ), const SizedBox(height: 8), Text('Channel note', style: theme.textTheme.titleSmall), const SizedBox(height: 4), diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index ed8f35f..5399e33 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -543,6 +543,17 @@ class _SettingsScreenState extends State { ), ), const Divider(height: 1), + ListTile( + key: const Key('settings-system-channels'), + leading: const Icon(Icons.vibration), + title: const Text('Vibration & LED'), + subtitle: const Text( + 'Configured per notification channel in Android ' + 'system settings.', + ), + onTap: () => unawaited(_permissions.openSettingsPage()), + ), + const Divider(height: 1), SwitchListTile( key: const Key('settings-notifications-enabled'), secondary: const Icon( diff --git a/pubspec.yaml b/pubspec.yaml index fd605e3..8cca9d4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.10+14 +version: 1.0.11+15 environment: sdk: ^3.11.1 diff --git a/test/chat_session_controller_test.dart b/test/chat_session_controller_test.dart index d233a98..f3ac8fd 100644 --- a/test/chat_session_controller_test.dart +++ b/test/chat_session_controller_test.dart @@ -14,6 +14,7 @@ import 'package:androidircx/dcc/services/dcc_service.dart'; import 'package:androidircx/dcc/services/dcc_socket_backend.dart'; import 'package:androidircx/core/storage/settings_repository.dart'; import 'package:androidircx/features/chat/application/chat_session_controller.dart'; +import 'package:androidircx/features/chat/data/channel_notification_rules_repository.dart'; import 'package:androidircx/features/chat/data/chat_session_persistence.dart'; import 'package:androidircx/features/chat/data/message_history_repository.dart'; import 'package:androidircx/features/chat/data/user_list_entry.dart'; @@ -3026,6 +3027,88 @@ void main() { controller.dispose(); }); + test('per-channel notification rules mute, filter, and promote', () async { + final transport = _FakeTransport(); + final service = IrcService(transportConnector: (_) async => transport); + final controller = ChatSessionController( + network: const NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + ), + ircService: service, + settingsRepository: _FakeSettingsRepository( + const AppSettings( + highlightWords: ['flutter'], + notificationsEnabled: true, + ), + ), + ); + + final received = []; + final sub = controller.notifications.listen(received.add); + await controller.start(); + + // Regular channel chatter does not notify by default. + transport.emit(':alice!u@h PRIVMSG #room :plain chatter'); + await Future.delayed(Duration.zero); + expect(received, isEmpty); + + // "All messages" promotes regular channel chat to a notification. + await controller.setChannelNotificationRule( + '#room', + ChannelNotificationRule.all, + ); + transport.emit(':alice!u@h PRIVMSG #room :more chatter'); + await Future.delayed(Duration.zero); + expect(received, hasLength(1)); + + // "Mentions only" drops plain chatter but keeps highlights. + await controller.setChannelNotificationRule( + '#room', + ChannelNotificationRule.mentionsOnly, + ); + transport.emit(':alice!u@h PRIVMSG #room :still chatter'); + transport.emit(':alice!u@h PRIVMSG #room :flutter rocks'); + await Future.delayed(Duration.zero); + expect(received, hasLength(2)); + expect( + received.last.channelKind, + ForegroundNotificationChannelKind.highlights, + ); + + // "Muted" suppresses even highlights. + await controller.setChannelNotificationRule( + '#room', + ChannelNotificationRule.mute, + ); + transport.emit(':alice!u@h PRIVMSG #room :flutter again'); + await Future.delayed(Duration.zero); + expect(received, hasLength(2)); + + // Rules persist per network and reload into a fresh controller. + final reloaded = ChatSessionController( + network: const NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + ), + ircService: IrcService(transportConnector: (_) async => _FakeTransport()), + ); + await reloaded.start(); + expect(reloaded.notificationRuleFor('#ROOM'), ChannelNotificationRule.mute); + + await sub.cancel(); + controller.dispose(); + reloaded.dispose(); + }); + test('suppresses notifications disabled in settings', () async { final transport = _FakeTransport(); final service = IrcService(transportConnector: (_) async => transport);