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..795c781 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.10'; +const appVersionCode = 14; 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..665682f 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,11 +80,14 @@ 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; + bool _connectedBannerDismissed = false; ChatSessionController get _controller => widget.controller; DccFilePicker get _filePicker => @@ -104,16 +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( @@ -307,50 +334,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( @@ -384,6 +368,11 @@ class _ChatScreenState extends State { _ConnectionBanner( controller: _controller, network: _controller.network, + connectedBannerDismissed: + _connectedBannerDismissed, + onDismissConnectedBanner: () => setState( + () => _connectedBannerDismissed = true, + ), ), if (_messageSearchVisible) _InlineMessageSearchBar( @@ -992,6 +981,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 +2845,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 +3172,7 @@ class _MessageList extends StatelessWidget { ), replyId: message.tags['draft/reply']!.trim(), ), - _IrcFormattedText( + IrcFormattedText( message.content, baseStyle: messageStyle, leading: leadingSpans, @@ -3287,188 +3369,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, + ); } } @@ -3854,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) { @@ -3865,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), @@ -3894,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/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..3149f35 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'; @@ -58,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; @@ -73,6 +79,7 @@ class _SettingsScreenState extends State { @override void didChangeDependencies() { super.didChangeDependencies(); + _syncMonetizationController(); if (_didResolveController) { return; } @@ -92,6 +99,7 @@ class _SettingsScreenState extends State { @override void dispose() { _settingsController?.removeListener(_syncFromController); + _monetizationController?.removeListener(_handleMonetizationChanged); _dccDownloadDirectoryController.dispose(); _mediaDownloadDirectoryController.dispose(); _customThemeController.dispose(); @@ -102,6 +110,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( @@ -141,6 +150,9 @@ class _SettingsScreenState extends State { ], ), const SizedBox(height: 12), + if (monetizationScope != null && + !monetizationScope.controller.hasNoAds) + ..._premiumAdsSettingsSection(monetizationScope), _SettingsSection( title: 'Appearance', children: [ @@ -454,7 +466,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 +516,9 @@ class _SettingsScreenState extends State { value: _settings.notifyPrivateMessages, onChanged: _settings.notificationsEnabled ? (value) => _saveSettings( - _settings.copyWith(notifyPrivateMessages: value), + _settings.copyWith( + notifyPrivateMessages: value, + ), ) : null, ), @@ -756,6 +772,9 @@ class _SettingsScreenState extends State { ], ), const SizedBox(height: 12), + if (monetizationScope != null && + monetizationScope.controller.hasNoAds) + ..._premiumAdsSettingsSection(monetizationScope), _SettingsSection( title: 'Help', children: [ @@ -844,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, @@ -1048,6 +1089,126 @@ 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) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PurchaseScreen( + monetizationController: scope.controller, + purchaseService: scope.purchaseService, + ), + ), + ); + 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) { + 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 +1265,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..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.6+9 +version: 1.0.10+14 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..a6bed26 --- /dev/null +++ b/test/monetization_settings_test.dart @@ -0,0 +1,121 @@ +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'; +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({}); + }); + + void useTallSettingsViewport(WidgetTester tester) { + tester.view.physicalSize = const Size(1200, 5000); + tester.view.devicePixelRatio = 1.0; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + } + + Future pumpSettingsWithMonetization( + WidgetTester tester, + MonetizationController controller, + RewardedAdService rewardedAdService, + StorePurchaseService purchaseService, + ) async { + 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)); + } + + 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, + ); + + 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(); + controller.dispose(); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 60674dc..9dfb7f3 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', ( @@ -794,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 { @@ -1030,7 +1082,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 +1173,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(); });