diff --git a/android/app/src/main/kotlin/com/androidircx/flutter/AndroidIrcxForegroundService.kt b/android/app/src/main/kotlin/com/androidircx/flutter/AndroidIrcxForegroundService.kt index 6eadd11..bdb7ccb 100644 --- a/android/app/src/main/kotlin/com/androidircx/flutter/AndroidIrcxForegroundService.kt +++ b/android/app/src/main/kotlin/com/androidircx/flutter/AndroidIrcxForegroundService.kt @@ -9,6 +9,7 @@ import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo import android.graphics.BitmapFactory +import android.graphics.Color import android.os.Build import android.os.IBinder @@ -219,12 +220,14 @@ class AndroidIrcxForegroundService : Service() { context.getString(R.string.notification_channel_highlights), context.getString(R.string.notification_channel_highlights_description), NotificationManager.IMPORTANCE_DEFAULT, + alerting = true, ), channel( CHANNEL_QUERIES, context.getString(R.string.notification_channel_queries), context.getString(R.string.notification_channel_queries_description), NotificationManager.IMPORTANCE_DEFAULT, + alerting = true, ), channel( CHANNEL_DCC_TRANSFERS, @@ -243,6 +246,7 @@ class AndroidIrcxForegroundService : Service() { context.getString(R.string.notification_channel_errors), context.getString(R.string.notification_channel_errors_description), NotificationManager.IMPORTANCE_HIGH, + alerting = true, ), ), ) @@ -253,9 +257,19 @@ class AndroidIrcxForegroundService : Service() { name: String, description: String, importance: Int, + alerting: Boolean = false, ): NotificationChannel { return NotificationChannel(id, name, importance).apply { this.description = description + if (alerting) { + // Vibration + notification LED for user-facing alerts. + // Users can still tune these per channel in system + // settings; existing installs keep their prior channel + // configuration (Android only applies this at creation). + enableVibration(true) + enableLights(true) + lightColor = Color.GREEN + } } } diff --git a/assets/sounds/bip.wav b/assets/sounds/bip.wav new file mode 100644 index 0000000..58cc8f1 Binary files /dev/null and b/assets/sounds/bip.wav differ diff --git a/assets/sounds/ctcp.wav b/assets/sounds/ctcp.wav new file mode 100644 index 0000000..3ccff64 Binary files /dev/null and b/assets/sounds/ctcp.wav differ diff --git a/assets/sounds/cuac.wav b/assets/sounds/cuac.wav new file mode 100644 index 0000000..780f5fe Binary files /dev/null and b/assets/sounds/cuac.wav differ diff --git a/assets/sounds/deop.wav b/assets/sounds/deop.wav new file mode 100644 index 0000000..1e8b0af Binary files /dev/null and b/assets/sounds/deop.wav differ diff --git a/assets/sounds/disconnected.wav b/assets/sounds/disconnected.wav new file mode 100644 index 0000000..1e41105 Binary files /dev/null and b/assets/sounds/disconnected.wav differ diff --git a/assets/sounds/fail.wav b/assets/sounds/fail.wav new file mode 100644 index 0000000..951e4e6 Binary files /dev/null and b/assets/sounds/fail.wav differ diff --git a/assets/sounds/join.wav b/assets/sounds/join.wav new file mode 100644 index 0000000..d089a3c Binary files /dev/null and b/assets/sounds/join.wav differ diff --git a/assets/sounds/kick.wav b/assets/sounds/kick.wav new file mode 100644 index 0000000..0aa916d Binary files /dev/null and b/assets/sounds/kick.wav differ diff --git a/assets/sounds/login.wav b/assets/sounds/login.wav new file mode 100644 index 0000000..a1e1220 Binary files /dev/null and b/assets/sounds/login.wav differ diff --git a/assets/sounds/notice.wav b/assets/sounds/notice.wav new file mode 100644 index 0000000..118b1ef Binary files /dev/null and b/assets/sounds/notice.wav differ diff --git a/assets/sounds/op.wav b/assets/sounds/op.wav new file mode 100644 index 0000000..b610d55 Binary files /dev/null and b/assets/sounds/op.wav differ diff --git a/assets/sounds/ring.wav b/assets/sounds/ring.wav new file mode 100644 index 0000000..add3f58 Binary files /dev/null and b/assets/sounds/ring.wav differ diff --git a/assets/sounds/send.wav b/assets/sounds/send.wav new file mode 100644 index 0000000..37fe687 Binary files /dev/null and b/assets/sounds/send.wav differ diff --git a/lib/app/app.dart b/lib/app/app.dart index dbdeca8..4a97b03 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -6,6 +6,8 @@ import 'package:androidircx/core/platform/foreground_connection_service.dart'; import 'package:androidircx/core/platform/screen_security.dart'; import 'package:androidircx/core/security/secret_storage.dart'; import 'package:androidircx/core/settings/app_settings_controller.dart'; +import 'package:androidircx/core/sound/audioplayers_sound_player.dart'; +import 'package:androidircx/core/sound/sound_service.dart'; import 'package:androidircx/core/storage/network_repository.dart'; import 'package:androidircx/core/storage/settings_repository.dart'; import 'package:androidircx/core/storage/shared_prefs_network_repository.dart'; @@ -31,6 +33,7 @@ class AndroidIrcxApp extends StatefulWidget { this.monetizationController, this.rewardedAdService, this.purchaseService, + this.soundService, }); final NetworkRepository? networkRepository; @@ -41,6 +44,9 @@ class AndroidIrcxApp extends StatefulWidget { final RewardedAdService? rewardedAdService; final StorePurchaseService? purchaseService; + /// Overridable for tests; defaults to the audioplayers-backed service. + final SoundService? soundService; + @override State createState() => _AndroidIrcxAppState(); } @@ -50,6 +56,7 @@ class _AndroidIrcxAppState extends State { late final MonetizationController _monetizationController; late final RewardedAdService _rewardedAdService; late final StorePurchaseService _purchaseService; + late final SoundService _soundService; late final bool _ownsMonetizationController; late final bool _ownsRewardedAdService; late final bool _ownsPurchaseService; @@ -73,6 +80,9 @@ class _AndroidIrcxAppState extends State { _purchaseService = widget.purchaseService ?? StorePurchaseService(monetizationController: _monetizationController); + _soundService = + widget.soundService ?? SoundService(player: AudioplayersSoundPlayer()); + unawaited(_soundService.load()); _settingsController.addListener(_applySettingsSideEffects); _settingsController.load(); unawaited(_monetizationController.initialize()); @@ -122,29 +132,32 @@ class _AndroidIrcxAppState extends State { controller: _monetizationController, rewardedAdService: _rewardedAdService, purchaseService: _purchaseService, - child: AppSettingsScope( - controller: _settingsController, - child: AnimatedBuilder( - animation: _settingsController, - builder: (context, _) { - return MaterialApp( - title: 'AndroidIRCx Flutter', - debugShowCheckedModeBanner: false, - theme: buildAppTheme(_settingsController.settings), - builder: (context, child) => AppLockGate( - enabled: - !_settingsController.isLoading && - _settingsController.settings.appLockEnabled, - child: MonetizationBanner( - controller: _monetizationController, - onboardingCompleted: - _settingsController.settings.onboardingCompleted, - child: child ?? const SizedBox.shrink(), + child: SoundScope( + service: _soundService, + child: AppSettingsScope( + controller: _settingsController, + child: AnimatedBuilder( + animation: _settingsController, + builder: (context, _) { + return MaterialApp( + title: 'AndroidIRCx Flutter', + debugShowCheckedModeBanner: false, + theme: buildAppTheme(_settingsController.settings), + builder: (context, child) => AppLockGate( + enabled: + !_settingsController.isLoading && + _settingsController.settings.appLockEnabled, + child: MonetizationBanner( + controller: _monetizationController, + onboardingCompleted: + _settingsController.settings.onboardingCompleted, + child: child ?? const SizedBox.shrink(), + ), ), - ), - home: _buildHome(), - ); - }, + home: _buildHome(), + ); + }, + ), ), ), ); @@ -171,6 +184,7 @@ class _AndroidIrcxAppState extends State { networkRepository: widget.networkRepository, foregroundConnectionService: widget.foregroundConnectionService, historyRepositoryLoader: widget.historyRepositoryLoader, + soundService: _soundService, ); } } diff --git a/lib/app/theme/app_theme.dart b/lib/app/theme/app_theme.dart index c67729e..4d422ff 100644 --- a/lib/app/theme/app_theme.dart +++ b/lib/app/theme/app_theme.dart @@ -163,7 +163,10 @@ class IrcUiTheme extends ThemeExtension { topic: palette.topic, messageBorder: palette.outline, messageFontSize: 14 * settings.messageFontScale, - messageFontFamily: settings.monospaceMessages ? 'monospace' : null, + messageFontFamily: switch (settings.messageFontFamily) { + 'system' => settings.monospaceMessages ? 'monospace' : null, + final family => family, + }, messagePadding: density.padding, messageSpacing: density.spacing, nickColorMode: settings.nickColorMode, diff --git a/lib/core/app/app_version.dart b/lib/core/app/app_version.dart index 795c781..08efebc 100644 --- a/lib/core/app/app_version.dart +++ b/lib/core/app/app_version.dart @@ -1,5 +1,5 @@ -const appVersionName = '1.0.10'; -const appVersionCode = 14; +const appVersionName = '1.0.11'; +const appVersionCode = 15; const appVersion = '$appVersionName+$appVersionCode'; const ctcpVersionReply = 'AndroidIRCX Flutter v$appVersion'; diff --git a/lib/core/backup/backup_service.dart b/lib/core/backup/backup_service.dart index 3fe4753..dd2c6c5 100644 --- a/lib/core/backup/backup_service.dart +++ b/lib/core/backup/backup_service.dart @@ -30,9 +30,9 @@ class BackupService { required NetworkRepository networkRepository, required SettingsRepository settingsRepository, required IdentityProfileRepository profileRepository, - }) : _networks = networkRepository, - _settings = settingsRepository, - _profiles = profileRepository; + }) : _networks = networkRepository, + _settings = settingsRepository, + _profiles = profileRepository; static const int backupVersion = 1; diff --git a/lib/core/diagnostics/crash_report_sanitizer.dart b/lib/core/diagnostics/crash_report_sanitizer.dart index 40e9717..496fa6b 100644 --- a/lib/core/diagnostics/crash_report_sanitizer.dart +++ b/lib/core/diagnostics/crash_report_sanitizer.dart @@ -35,14 +35,8 @@ class CrashReportSanitizer { } var out = input; out = out.replaceAll(_pemBlock, redaction); - out = out.replaceAllMapped( - _authCommand, - (m) => '${m.group(1)} $redaction', - ); - out = out.replaceAllMapped( - _keyValue, - (m) => '${m.group(1)}=$redaction', - ); + out = out.replaceAllMapped(_authCommand, (m) => '${m.group(1)} $redaction'); + out = out.replaceAllMapped(_keyValue, (m) => '${m.group(1)}=$redaction'); out = out.replaceAll(_longToken, redaction); return out; } diff --git a/lib/core/firebase/firebase_service.dart b/lib/core/firebase/firebase_service.dart index 98b496b..f194342 100644 --- a/lib/core/firebase/firebase_service.dart +++ b/lib/core/firebase/firebase_service.dart @@ -32,8 +32,9 @@ class FirebaseService { } await Firebase.initializeApp(); await FirebaseAppCheck.instance.activate( - providerAndroid: - kReleaseMode ? AndroidPlayIntegrityProvider() : AndroidDebugProvider(), + providerAndroid: kReleaseMode + ? AndroidPlayIntegrityProvider() + : AndroidDebugProvider(), ); _initialized = true; diff --git a/lib/core/models/app_settings.dart b/lib/core/models/app_settings.dart index 6cff832..5f04321 100644 --- a/lib/core/models/app_settings.dart +++ b/lib/core/models/app_settings.dart @@ -1,5 +1,11 @@ enum NoticeRoutingMode { server, active, notice, private } +/// How sender nicks are decorated in the message list. +enum NickDisplayFormat { plain, angle, colon, bracket } + +/// Where the timestamp sits relative to the sender nick. +enum TimestampPosition { afterNick, beforeNick } + enum AppThemePreset { light, dark, ircap, custom } enum MessageDensity { compact, comfortable, relaxed } @@ -19,6 +25,7 @@ class AppSettings { this.messageFontScale = 1.0, this.messageDensity = MessageDensity.comfortable, this.monospaceMessages = false, + this.messageFontFamily = 'system', this.nickColorMode = NickColorMode.soft, this.onboardingCompleted = false, this.appLockEnabled = false, @@ -31,8 +38,14 @@ class AppSettings { this.notificationSound = true, this.hideJoinPartQuit = false, this.showTimestamps = true, + this.timestampFormat = 'HH:mm', + this.timestampPosition = TimestampPosition.afterNick, + this.nickDisplayFormat = NickDisplayFormat.plain, this.enterToSend = true, this.showSendButton = true, + this.composerAutocorrect = true, + this.composerSuggestions = true, + this.composerCapitalizeSentences = false, this.highlightWords = const [], this.autoAwayEnabled = false, this.autoAwayMinutes = 10, @@ -52,7 +65,14 @@ class AppSettings { final String customThemeJson; final double messageFontScale; final MessageDensity messageDensity; + + /// Legacy monospace toggle, kept for stored-settings migration; superseded + /// by [messageFontFamily] whenever that is not 'system'. final bool monospaceMessages; + + /// Message font family: 'system' for the platform default, otherwise an + /// Android family name ('monospace', 'serif', 'sans-serif', ...). + final String messageFontFamily; final NickColorMode nickColorMode; /// Whether the first-run onboarding + consent flow has been completed. @@ -79,9 +99,20 @@ class AppSettings { // Display / writing. final bool hideJoinPartQuit; final bool showTimestamps; + + /// Timestamp pattern for message lines ('HH:mm', 'HH:mm:ss', 'h:mm a', + /// 'h:mm:ss a'). + final String timestampFormat; + final TimestampPosition timestampPosition; + final NickDisplayFormat nickDisplayFormat; final bool enterToSend; final bool showSendButton; + /// Composer keyboard behavior. + final bool composerAutocorrect; + final bool composerSuggestions; + final bool composerCapitalizeSentences; + /// Extra words (besides your nick) that trigger a highlight notification. final List highlightWords; final bool autoAwayEnabled; @@ -109,6 +140,7 @@ class AppSettings { double? messageFontScale, MessageDensity? messageDensity, bool? monospaceMessages, + String? messageFontFamily, NickColorMode? nickColorMode, bool? onboardingCompleted, bool? appLockEnabled, @@ -121,8 +153,14 @@ class AppSettings { bool? notificationSound, bool? hideJoinPartQuit, bool? showTimestamps, + String? timestampFormat, + TimestampPosition? timestampPosition, + NickDisplayFormat? nickDisplayFormat, bool? enterToSend, bool? showSendButton, + bool? composerAutocorrect, + bool? composerSuggestions, + bool? composerCapitalizeSentences, List? highlightWords, bool? autoAwayEnabled, int? autoAwayMinutes, @@ -149,6 +187,7 @@ class AppSettings { ), messageDensity: messageDensity ?? this.messageDensity, monospaceMessages: monospaceMessages ?? this.monospaceMessages, + messageFontFamily: messageFontFamily ?? this.messageFontFamily, nickColorMode: nickColorMode ?? this.nickColorMode, onboardingCompleted: onboardingCompleted ?? this.onboardingCompleted, appLockEnabled: appLockEnabled ?? this.appLockEnabled, @@ -162,8 +201,15 @@ class AppSettings { notificationSound: notificationSound ?? this.notificationSound, hideJoinPartQuit: hideJoinPartQuit ?? this.hideJoinPartQuit, showTimestamps: showTimestamps ?? this.showTimestamps, + timestampFormat: timestampFormat ?? this.timestampFormat, + timestampPosition: timestampPosition ?? this.timestampPosition, + nickDisplayFormat: nickDisplayFormat ?? this.nickDisplayFormat, enterToSend: enterToSend ?? this.enterToSend, showSendButton: showSendButton ?? this.showSendButton, + composerAutocorrect: composerAutocorrect ?? this.composerAutocorrect, + composerSuggestions: composerSuggestions ?? this.composerSuggestions, + composerCapitalizeSentences: + composerCapitalizeSentences ?? this.composerCapitalizeSentences, highlightWords: highlightWords ?? this.highlightWords, autoAwayEnabled: autoAwayEnabled ?? this.autoAwayEnabled, autoAwayMinutes: autoAwayMinutes ?? this.autoAwayMinutes, @@ -188,6 +234,7 @@ class AppSettings { 'messageFontScale': messageFontScale, 'messageDensity': messageDensity.name, 'monospaceMessages': monospaceMessages, + 'messageFontFamily': messageFontFamily, 'nickColorMode': nickColorMode.name, 'onboardingCompleted': onboardingCompleted, 'appLockEnabled': appLockEnabled, @@ -200,8 +247,14 @@ class AppSettings { 'notificationSound': notificationSound, 'hideJoinPartQuit': hideJoinPartQuit, 'showTimestamps': showTimestamps, + 'timestampFormat': timestampFormat, + 'timestampPosition': timestampPosition.name, + 'nickDisplayFormat': nickDisplayFormat.name, 'enterToSend': enterToSend, 'showSendButton': showSendButton, + 'composerAutocorrect': composerAutocorrect, + 'composerSuggestions': composerSuggestions, + 'composerCapitalizeSentences': composerCapitalizeSentences, 'highlightWords': highlightWords, 'autoAwayEnabled': autoAwayEnabled, 'autoAwayMinutes': autoAwayMinutes, @@ -241,6 +294,13 @@ class AppSettings { MessageDensity.comfortable, ), monospaceMessages: (json['monospaceMessages'] as bool?) ?? false, + messageFontFamily: + (json['messageFontFamily'] as String?)?.trim().isNotEmpty ?? false + ? (json['messageFontFamily']! as String).trim() + // Migrate the legacy monospace toggle into the font family. + : ((json['monospaceMessages'] as bool?) ?? false) + ? 'monospace' + : 'system', nickColorMode: _enumByName( NickColorMode.values, json['nickColorMode'], @@ -257,8 +317,26 @@ class AppSettings { notificationSound: (json['notificationSound'] as bool?) ?? true, hideJoinPartQuit: (json['hideJoinPartQuit'] as bool?) ?? false, showTimestamps: (json['showTimestamps'] as bool?) ?? true, + timestampFormat: + (json['timestampFormat'] as String?)?.trim().isNotEmpty ?? false + ? (json['timestampFormat']! as String).trim() + : 'HH:mm', + timestampPosition: _enumByName( + TimestampPosition.values, + json['timestampPosition'], + TimestampPosition.afterNick, + ), + nickDisplayFormat: _enumByName( + NickDisplayFormat.values, + json['nickDisplayFormat'], + NickDisplayFormat.plain, + ), enterToSend: (json['enterToSend'] as bool?) ?? true, showSendButton: (json['showSendButton'] as bool?) ?? true, + composerAutocorrect: (json['composerAutocorrect'] as bool?) ?? true, + composerSuggestions: (json['composerSuggestions'] as bool?) ?? true, + composerCapitalizeSentences: + (json['composerCapitalizeSentences'] as bool?) ?? false, highlightWords: _stringList(json['highlightWords']), autoAwayEnabled: (json['autoAwayEnabled'] as bool?) ?? false, autoAwayMinutes: (json['autoAwayMinutes'] as num?)?.toInt() ?? 10, diff --git a/lib/core/models/network_config.dart b/lib/core/models/network_config.dart index 09fb41b..197d1bd 100644 --- a/lib/core/models/network_config.dart +++ b/lib/core/models/network_config.dart @@ -37,6 +37,8 @@ class NetworkConfig { this.profileLabel, this.profileGroup, this.identityProfileId, + this.encoding = 'utf-8', + this.encodingUtf8Fallback = false, }); final String id; @@ -76,6 +78,16 @@ class NetworkConfig { /// SASL account override this network's identity on connect. final String? identityProfileId; + /// Character encoding for wire text (canonical lower-case label, e.g. + /// 'utf-8', 'windows-1251'). IRC has no in-band charset negotiation, so + /// legacy networks may need a non-UTF-8 charset. + final String encoding; + + /// When true and [encoding] is a legacy charset, incoming lines are decoded + /// as UTF-8 first (falling back to the legacy charset per line) and + /// outgoing lines are sent as UTF-8. + final bool encodingUtf8Fallback; + NetworkConfig copyWith({ String? id, String? name, @@ -106,6 +118,8 @@ class NetworkConfig { String? profileLabel, String? profileGroup, String? identityProfileId, + String? encoding, + bool? encodingUtf8Fallback, }) { return NetworkConfig( id: id ?? this.id, @@ -137,6 +151,8 @@ class NetworkConfig { profileLabel: profileLabel ?? this.profileLabel, profileGroup: profileGroup ?? this.profileGroup, identityProfileId: identityProfileId ?? this.identityProfileId, + encoding: encoding ?? this.encoding, + encodingUtf8Fallback: encodingUtf8Fallback ?? this.encodingUtf8Fallback, ); } @@ -171,6 +187,8 @@ class NetworkConfig { 'profileLabel': profileLabel, 'profileGroup': profileGroup, 'identityProfileId': identityProfileId, + 'encoding': encoding, + 'encodingUtf8Fallback': encodingUtf8Fallback, }; } @@ -227,6 +245,8 @@ class NetworkConfig { profileLabel: _nonEmptyString(json['profileLabel']), profileGroup: _nonEmptyString(json['profileGroup']), identityProfileId: _nonEmptyString(json['identityProfileId']), + encoding: _nonEmptyString(json['encoding'])?.toLowerCase() ?? 'utf-8', + encodingUtf8Fallback: (json['encodingUtf8Fallback'] as bool?) ?? false, ); } diff --git a/lib/core/platform/app_permissions.dart b/lib/core/platform/app_permissions.dart index eb749fe..3a10267 100644 --- a/lib/core/platform/app_permissions.dart +++ b/lib/core/platform/app_permissions.dart @@ -8,6 +8,11 @@ abstract class AppPermissions { Future requestNotifications(); Future hasNotifications(); + /// Asks Android to exempt the app from battery optimization so background + /// IRC connections are not killed by Doze. + Future requestIgnoreBatteryOptimizations(); + Future hasIgnoreBatteryOptimizations(); + /// Opens the OS app-settings page (used after a permanent denial). Future openSettingsPage(); } @@ -36,6 +41,14 @@ class PermissionHandlerAppPermissions implements AppPermissions { Future hasNotifications() async => (await Permission.notification.status).isGranted; + @override + Future requestIgnoreBatteryOptimizations() async => + _map(await Permission.ignoreBatteryOptimizations.request()); + + @override + Future hasIgnoreBatteryOptimizations() async => + (await Permission.ignoreBatteryOptimizations.status).isGranted; + @override Future openSettingsPage() async { await openAppSettings(); diff --git a/lib/core/platform/screen_security.dart b/lib/core/platform/screen_security.dart index 87b55d2..0893754 100644 --- a/lib/core/platform/screen_security.dart +++ b/lib/core/platform/screen_security.dart @@ -4,8 +4,9 @@ import 'package:flutter/services.dart'; class ScreenSecurity { const ScreenSecurity(); - static const MethodChannel _channel = - MethodChannel('androidircx/screen_security'); + static const MethodChannel _channel = MethodChannel( + 'androidircx/screen_security', + ); Future setSecure(bool secure) async { try { diff --git a/lib/core/presets/server_preset_service.dart b/lib/core/presets/server_preset_service.dart index cac0e3a..a1ef258 100644 --- a/lib/core/presets/server_preset_service.dart +++ b/lib/core/presets/server_preset_service.dart @@ -12,23 +12,20 @@ typedef PresetHttpGet = Future Function(Uri url); /// `DEFAULT_SERVER`). class ServerPresetService { ServerPresetService({PresetHttpGet? httpGet}) - : _httpGet = httpGet ?? _defaultHttpGet; + : _httpGet = httpGet ?? _defaultHttpGet; final PresetHttpGet _httpGet; - static final Uri endpoint = - Uri.parse('https://irc.dbase.in.rs/api/irc/server-presets'); + static final Uri endpoint = Uri.parse( + 'https://irc.dbase.in.rs/api/irc/server-presets', + ); /// The old app's default network, used when the directory cannot be reached. static const ServerPreset fallbackPreset = ServerPreset( networkName: 'DBase', averageUsers: 0, servers: [ - ServerPresetServer( - hostname: 'irc.dbase.in.rs', - port: 6697, - useSsl: true, - ), + ServerPresetServer(hostname: 'irc.dbase.in.rs', port: 6697, useSsl: true), ], ); diff --git a/lib/core/review/review_prompt_service.dart b/lib/core/review/review_prompt_service.dart index 88fbc6a..1ae468c 100644 --- a/lib/core/review/review_prompt_service.dart +++ b/lib/core/review/review_prompt_service.dart @@ -7,10 +7,9 @@ class ReviewPromptService { ReviewPromptService({ Future Function()? isAvailable, Future Function()? requestReview, - }) : _isAvailable = - isAvailable ?? (() => InAppReview.instance.isAvailable()), - _requestReview = - requestReview ?? (() => InAppReview.instance.requestReview()); + }) : _isAvailable = isAvailable ?? (() => InAppReview.instance.isAvailable()), + _requestReview = + requestReview ?? (() => InAppReview.instance.requestReview()); static const String _launchKey = 'androidircx.launchCount'; static const String _promptedKey = 'androidircx.reviewPrompted'; diff --git a/lib/core/security/certificate_store.dart b/lib/core/security/certificate_store.dart index 4c336b3..d113cdb 100644 --- a/lib/core/security/certificate_store.dart +++ b/lib/core/security/certificate_store.dart @@ -79,8 +79,9 @@ class CertificateStore { final passphrase = await _storage.getSecret( _key(networkId, NetworkSecretField.clientKeyPassphrase), ); - final normalizedPassphrase = - (passphrase == null || passphrase.isEmpty) ? null : passphrase; + final normalizedPassphrase = (passphrase == null || passphrase.isEmpty) + ? null + : passphrase; final pkcs12 = await _storage.getSecret( _key(networkId, NetworkSecretField.clientPkcs12), @@ -164,24 +165,18 @@ void validateClientCertificate(ClientCertificate certificate) { } return; } - if (!_isPemBlock( - certificate.certificatePem, - const ['CERTIFICATE'], - )) { + if (!_isPemBlock(certificate.certificatePem, const ['CERTIFICATE'])) { throw const CertificateFormatException( 'Client certificate must be a PEM block ' '(-----BEGIN CERTIFICATE----- … -----END CERTIFICATE-----).', ); } - if (!_isPemBlock( - certificate.privateKeyPem, - const [ - 'PRIVATE KEY', - 'RSA PRIVATE KEY', - 'EC PRIVATE KEY', - 'ENCRYPTED PRIVATE KEY', - ], - )) { + if (!_isPemBlock(certificate.privateKeyPem, const [ + 'PRIVATE KEY', + 'RSA PRIVATE KEY', + 'EC PRIVATE KEY', + 'ENCRYPTED PRIVATE KEY', + ])) { throw const CertificateFormatException( 'Client private key must be a PEM private-key block.', ); diff --git a/lib/core/security/history_encryption_key_manager.dart b/lib/core/security/history_encryption_key_manager.dart index e09e6bd..7d6114b 100644 --- a/lib/core/security/history_encryption_key_manager.dart +++ b/lib/core/security/history_encryption_key_manager.dart @@ -35,9 +35,9 @@ class HistoryEncryptionKeyManager { required SecretStorage storage, required HistoryUnlockAuthenticator authenticator, List Function()? keyBytesGenerator, - }) : _storage = storage, - _authenticator = authenticator, - _keyBytesGenerator = keyBytesGenerator ?? _defaultKeyBytes; + }) : _storage = storage, + _authenticator = authenticator, + _keyBytesGenerator = keyBytesGenerator ?? _defaultKeyBytes; static const String storageKey = 'androidircx.history.databaseKey'; static const int keyLengthBytes = 32; // 256-bit diff --git a/lib/core/sound/audioplayers_sound_player.dart b/lib/core/sound/audioplayers_sound_player.dart new file mode 100644 index 0000000..a95b5e1 --- /dev/null +++ b/lib/core/sound/audioplayers_sound_player.dart @@ -0,0 +1,18 @@ +import 'package:androidircx/core/sound/sound_service.dart'; +import 'package:audioplayers/audioplayers.dart'; + +/// Production [SoundPlayer] backed by the `audioplayers` plugin. A single +/// player instance is reused; a new event sound cuts off the previous one. +class AudioplayersSoundPlayer implements SoundPlayer { + AudioplayersSoundPlayer() { + _player.setReleaseMode(ReleaseMode.stop); + } + + final AudioPlayer _player = AudioPlayer(playerId: 'event-sounds'); + + @override + Future play(String assetPath, double volume) async { + await _player.stop(); + await _player.play(AssetSource(assetPath), volume: volume); + } +} diff --git a/lib/core/sound/sound_service.dart b/lib/core/sound/sound_service.dart new file mode 100644 index 0000000..8ec3284 --- /dev/null +++ b/lib/core/sound/sound_service.dart @@ -0,0 +1,216 @@ +import 'dart:convert'; + +import 'package:flutter/widgets.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Event types that can trigger a notification sound. Mirrors the previous +/// app's sound events, minus ones with no trigger in this client yet. +enum SoundEvent { + mention, + privateMessage, + notice, + join, + kick, + ctcp, + disconnect, + login, + send, + fail, + ring, +} + +const Map soundEventLabels = { + SoundEvent.mention: 'Mention / highlight', + SoundEvent.privateMessage: 'Private message', + SoundEvent.notice: 'Notice', + SoundEvent.join: 'User join', + SoundEvent.kick: 'Kicked from channel', + SoundEvent.ctcp: 'CTCP request', + SoundEvent.disconnect: 'Disconnected', + SoundEvent.login: 'Connected', + SoundEvent.send: 'Message sent', + SoundEvent.fail: 'Error', + SoundEvent.ring: 'DCC offer', +}; + +/// Asset filename for each event (bundled under assets/sounds/). +const Map soundEventAssets = { + SoundEvent.mention: 'cuac.wav', + SoundEvent.privateMessage: 'bip.wav', + SoundEvent.notice: 'notice.wav', + SoundEvent.join: 'join.wav', + SoundEvent.kick: 'kick.wav', + SoundEvent.ctcp: 'ctcp.wav', + SoundEvent.disconnect: 'disconnected.wav', + SoundEvent.login: 'login.wav', + SoundEvent.send: 'send.wav', + SoundEvent.fail: 'fail.wav', + SoundEvent.ring: 'ring.wav', +}; + +/// Events that make noise out of the box; the rest stay opt-in. +const Set _defaultEnabledEvents = { + SoundEvent.mention, + SoundEvent.privateMessage, + SoundEvent.disconnect, + SoundEvent.login, + SoundEvent.ring, +}; + +class SoundSettings { + const SoundSettings({ + this.enabled = true, + this.masterVolume = 0.7, + this.eventEnabled = const {}, + }); + + /// Global sound switch. + final bool enabled; + + /// 0.0–1.0 volume applied to every event sound. + final double masterVolume; + + /// Per-event overrides; events absent from the map use their default. + final Map eventEnabled; + + bool isEventEnabled(SoundEvent event) { + return eventEnabled[event] ?? _defaultEnabledEvents.contains(event); + } + + SoundSettings copyWith({ + bool? enabled, + double? masterVolume, + Map? eventEnabled, + }) { + return SoundSettings( + enabled: enabled ?? this.enabled, + masterVolume: (masterVolume ?? this.masterVolume).clamp(0.0, 1.0), + eventEnabled: eventEnabled ?? this.eventEnabled, + ); + } + + SoundSettings withEvent(SoundEvent event, bool value) { + return copyWith(eventEnabled: {...eventEnabled, event: value}); + } + + Map toJson() { + return { + 'enabled': enabled, + 'masterVolume': masterVolume, + 'events': { + for (final entry in eventEnabled.entries) entry.key.name: entry.value, + }, + }; + } + + factory SoundSettings.fromJson(Map json) { + final rawEvents = json['events']; + final events = {}; + if (rawEvents is Map) { + rawEvents.forEach((key, value) { + if (key is! String || value is! bool) { + return; + } + for (final event in SoundEvent.values) { + if (event.name == key) { + events[event] = value; + } + } + }); + } + return SoundSettings( + enabled: (json['enabled'] as bool?) ?? true, + masterVolume: ((json['masterVolume'] as num?)?.toDouble() ?? 0.7).clamp( + 0.0, + 1.0, + ), + eventEnabled: events, + ); + } +} + +/// Playback backend; the production implementation uses `audioplayers`, +/// tests inject a fake. +abstract class SoundPlayer { + Future play(String assetPath, double volume); +} + +/// Plays short event sounds per user settings; persists settings in +/// shared preferences. +class SoundService extends ChangeNotifier { + SoundService({required SoundPlayer player, this.storageKey = 'soundSettings'}) + : _player = player; + + final SoundPlayer _player; + final String storageKey; + + SoundSettings _settings = const SoundSettings(); + bool _loaded = false; + + SoundSettings get settings => _settings; + + Future load() async { + if (_loaded) { + return; + } + _loaded = true; + try { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(storageKey); + if (raw == null || raw.isEmpty) { + return; + } + final decoded = jsonDecode(raw); + if (decoded is Map) { + _settings = SoundSettings.fromJson(decoded); + notifyListeners(); + } + } catch (_) { + // Corrupt settings fall back to defaults. + } + } + + Future updateSettings(SoundSettings next) async { + _settings = next; + notifyListeners(); + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(storageKey, jsonEncode(next.toJson())); + } + + /// Plays the sound for [event] if sounds and the event are enabled. + /// Never throws: sound failures must not break message handling. + Future playEvent(SoundEvent event) async { + if (!_settings.enabled || + _settings.masterVolume <= 0 || + !_settings.isEventEnabled(event)) { + return; + } + await _playAsset(soundEventAssets[event]!); + } + + /// Plays [event]'s sound unconditionally (settings preview). + Future previewEvent(SoundEvent event) { + return _playAsset(soundEventAssets[event]!); + } + + Future _playAsset(String fileName) async { + try { + await _player.play('sounds/$fileName', _settings.masterVolume); + } catch (_) { + // Missing/undecodable asset or platform audio failure: stay silent. + } + } +} + +/// Shares one [SoundService] between chat sessions and the settings UI. +class SoundScope extends InheritedNotifier { + const SoundScope({ + super.key, + required SoundService service, + required super.child, + }) : super(notifier: service); + + static SoundService? maybeOf(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType()?.notifier; + } +} diff --git a/lib/core/storage/identity_profile_repository.dart b/lib/core/storage/identity_profile_repository.dart index 2786d8b..c483044 100644 --- a/lib/core/storage/identity_profile_repository.dart +++ b/lib/core/storage/identity_profile_repository.dart @@ -16,12 +16,14 @@ List normalizeProfiles(List profiles) { final others = profiles.where( (profile) => profile.id != IdentityProfile.defaultProfileId, ); - return List.unmodifiable( - [IdentityProfile.defaultProfile, ...others], - ); + return List.unmodifiable([ + IdentityProfile.defaultProfile, + ...others, + ]); } -class SharedPrefsIdentityProfileRepository implements IdentityProfileRepository { +class SharedPrefsIdentityProfileRepository + implements IdentityProfileRepository { static const _storageKey = 'androidircx.identityProfiles'; @override @@ -85,10 +87,10 @@ class SharedPrefsIdentityProfileRepository implements IdentityProfileRepository class InMemoryIdentityProfileRepository implements IdentityProfileRepository { InMemoryIdentityProfileRepository([List initial = const []]) - : _profiles = [ - for (final profile in initial) - if (profile.id != IdentityProfile.defaultProfileId) profile, - ]; + : _profiles = [ + for (final profile in initial) + if (profile.id != IdentityProfile.defaultProfileId) profile, + ]; final List _profiles; diff --git a/lib/core/storage/in_memory_network_repository.dart b/lib/core/storage/in_memory_network_repository.dart index 90ae30d..a613043 100644 --- a/lib/core/storage/in_memory_network_repository.dart +++ b/lib/core/storage/in_memory_network_repository.dart @@ -2,9 +2,8 @@ import 'package:androidircx/core/models/network_config.dart'; import 'package:androidircx/core/storage/network_repository.dart'; class InMemoryNetworkRepository implements NetworkRepository { - InMemoryNetworkRepository([ - List? seed, - ]) : _networks = List.from(seed ?? _defaultSeed); + InMemoryNetworkRepository([List? seed]) + : _networks = List.from(seed ?? _defaultSeed); final List _networks; @@ -28,9 +27,7 @@ class InMemoryNetworkRepository implements NetworkRepository { @override Future> loadNetworks() async { - return List.unmodifiable( - _networks.map(_normalizeNetwork), - ); + return List.unmodifiable(_networks.map(_normalizeNetwork)); } @override diff --git a/lib/dcc/services/dcc_socket_backend_stub.dart b/lib/dcc/services/dcc_socket_backend_stub.dart index 226a9ee..8cb6c26 100644 --- a/lib/dcc/services/dcc_socket_backend_stub.dart +++ b/lib/dcc/services/dcc_socket_backend_stub.dart @@ -15,4 +15,5 @@ class _UnsupportedDccSocketBackend implements DccSocketBackend { } } -DccSocketBackend createPlatformDccSocketBackend() => _UnsupportedDccSocketBackend(); +DccSocketBackend createPlatformDccSocketBackend() => + _UnsupportedDccSocketBackend(); diff --git a/lib/features/bootstrap/presentation/bootstrap_screen.dart b/lib/features/bootstrap/presentation/bootstrap_screen.dart index 9834d3b..2e1a54c 100644 --- a/lib/features/bootstrap/presentation/bootstrap_screen.dart +++ b/lib/features/bootstrap/presentation/bootstrap_screen.dart @@ -5,6 +5,8 @@ import 'package:androidircx/core/review/review_prompt_service.dart'; import 'package:androidircx/core/security/history_encryption_key_manager.dart'; import 'package:androidircx/core/security/local_auth_history_unlock.dart'; import 'package:androidircx/core/security/secret_storage.dart'; +import 'package:androidircx/core/sound/audioplayers_sound_player.dart'; +import 'package:androidircx/core/sound/sound_service.dart'; import 'package:androidircx/core/storage/network_repository.dart'; import 'package:androidircx/core/storage/shared_prefs_network_repository.dart'; import 'package:androidircx/features/chat/application/chat_session_controller.dart'; @@ -34,11 +36,15 @@ class BootstrapScreen extends StatefulWidget { this.foregroundConnectionService = const MethodChannelForegroundConnectionService(), this.historyRepositoryLoader, + this.soundService, }); final NetworkRepository? networkRepository; final ForegroundConnectionService foregroundConnectionService; + /// Overridable for tests; defaults to the audioplayers-backed service. + final SoundService? soundService; + /// Overridable for tests; defaults to the biometric/PIN-gated encrypted /// history repository. When it returns null (e.g. auth declined), sessions /// keep messages in memory only. @@ -54,6 +60,7 @@ class _BootstrapScreenState extends State late final NetworkListController _controller; late final SessionRegistry _sessionRegistry; final UserListsRepository _userListsRepository = UserListsRepository(); + late final SoundService _soundService; MessageHistoryRepository? _historyRepository; bool _bootstrapComplete = false; @@ -62,12 +69,16 @@ class _BootstrapScreenState extends State super.initState(); WidgetsBinding.instance.addObserver(this); _foregroundConnectionService = widget.foregroundConnectionService; + _soundService = + widget.soundService ?? SoundService(player: AudioplayersSoundPlayer()); + unawaited(_soundService.load()); _sessionRegistry = SessionRegistry( foregroundService: _foregroundConnectionService, sessionFactory: (network) => ChatSessionController( network: network, historyRepository: _historyRepository, userListsRepository: _userListsRepository, + soundService: _soundService, ), ); _controller = NetworkListController( diff --git a/lib/features/chat/application/chat_session_controller.dart b/lib/features/chat/application/chat_session_controller.dart index 6e78292..ec9068f 100644 --- a/lib/features/chat/application/chat_session_controller.dart +++ b/lib/features/chat/application/chat_session_controller.dart @@ -15,6 +15,7 @@ import 'package:androidircx/features/chat/application/command_service.dart'; import 'package:androidircx/features/chat/application/message_history_formatter.dart'; import 'package:androidircx/core/storage/settings_repository.dart'; import 'package:androidircx/core/storage/shared_prefs_settings_repository.dart'; +import 'package:androidircx/features/chat/data/channel_notification_rules_repository.dart'; import 'package:androidircx/features/chat/data/chat_session_persistence.dart'; import 'package:androidircx/features/chat/data/message_history_repository.dart'; import 'package:androidircx/features/chat/data/user_list_entry.dart'; @@ -23,6 +24,7 @@ import 'package:androidircx/features/chat/presentation/join_channel_dialog.dart' import 'package:androidircx/core/models/channel_list_entry.dart'; import 'package:androidircx/core/security/certificate_store.dart'; import 'package:androidircx/core/security/secret_storage.dart'; +import 'package:androidircx/core/sound/sound_service.dart'; import 'package:androidircx/irc/models/irc_message_frame.dart'; import 'package:androidircx/irc/parser/ctcp.dart'; import 'package:androidircx/irc/parser/dcc_parser.dart'; @@ -199,6 +201,8 @@ class ChatSessionController extends ChangeNotifier { SettingsRepository? settingsRepository, CommandService? commandService, UserListsRepository? userListsRepository, + SoundService? soundService, + ChannelNotificationRulesRepository? channelNotificationRulesRepository, int maxReconnectAttempts = 6, Duration reconnectBaseDelay = const Duration(seconds: 2), Duration reconnectMaxDelay = const Duration(seconds: 60), @@ -216,6 +220,10 @@ class ChatSessionController extends ChangeNotifier { settingsRepository ?? SharedPrefsSettingsRepository(), _commandService = commandService ?? CommandService(), _userListsRepository = userListsRepository, + _soundService = soundService, + _channelNotificationRulesRepository = + channelNotificationRulesRepository ?? + ChannelNotificationRulesRepository(), _maxReconnectAttempts = maxReconnectAttempts, _reconnectBaseDelay = reconnectBaseDelay, _reconnectMaxDelay = reconnectMaxDelay, @@ -241,6 +249,15 @@ class ChatSessionController extends ChangeNotifier { final SettingsRepository _settingsRepository; final CommandService _commandService; final UserListsRepository? _userListsRepository; + final SoundService? _soundService; + final ChannelNotificationRulesRepository _channelNotificationRulesRepository; + + /// Per-tab notification overrides keyed by lower-case target name. + final Map _channelNotificationRules = {}; + + /// Last connection phase a sound was played for, so repeated snapshots in + /// the same phase (or reconnect retries) do not re-trigger sounds. + ConnectionPhase? _lastSoundedPhase; List _userListEntries = const []; bool _userListEntriesLoaded = false; final int _maxReconnectAttempts; @@ -479,6 +496,9 @@ class ChatSessionController extends ChangeNotifier { int get activityCount => _tabs.where((tab) => tab.hasActivity).length; bool get hasActivity => activityCount > 0; String? get activeChannelTopic => _channelTopics[activeTabId]; + + /// Topic for any tab (used by the per-channel settings screen). + String? topicForTab(String tabId) => _channelTopics[tabId]; String? get activeChannelModes => _channelModes[activeTabId]; DateTime? get activeReadMarker => _readMarkers[activeTabId]; List get activeTypingUsers => List.unmodifiable( @@ -616,6 +636,32 @@ class ChatSessionController extends ChangeNotifier { return summary; } + /// Sends a reaction to [message] over TAGMSG and records it locally so the + /// chip shows immediately. Returns false when the message has no msgid or + /// its tab cannot receive reactions. + Future reactToMessage(IrcMessage message, String emoji) async { + final msgid = (message.tags['msgid'] ?? '').trim(); + final normalizedEmoji = emoji.trim(); + if (msgid.isEmpty || normalizedEmoji.isEmpty) { + return false; + } + final tab = _findTab(message.tabId); + if (tab == null || + (tab.type != ChatTabType.channel && tab.type != ChatTabType.query)) { + return false; + } + await _ircService.sendReaction( + target: tab.name, + msgid: msgid, + emoji: normalizedEmoji, + ); + // Record locally; the echoed TAGMSG re-recording the same nick+emoji is + // idempotent because reactions are per-emoji nick sets. + _recordReaction(msgid, normalizedEmoji, currentNick); + notifyListeners(); + return true; + } + String userDetailsForNick(String nick) { final info = userInfoForNick(nick); if (info.nick.trim().isEmpty) { @@ -1068,6 +1114,7 @@ class ChatSessionController extends ChangeNotifier { await _commandService.load(); await _loadPersistedState(); await _loadAutoModeEntries(); + await _loadChannelNotificationRules(); if (_isDisposed) { return; } @@ -1200,11 +1247,30 @@ class ChatSessionController extends ChangeNotifier { } final normalizedReply = (replyTo ?? '').trim(); + // Queue only when clearly offline; while connecting/registering the + // transport is live and sends keep their previous behavior. + final isOffline = switch (_connection.phase) { + ConnectionPhase.idle || + ConnectionPhase.disconnected || + ConnectionPhase.error || + ConnectionPhase.reconnecting => true, + _ => false, + }; + if (isOffline) { + _queueOfflineMessage( + tabId: activeTab.id, + target: activeTab.name, + text: text, + replyTo: normalizedReply.isEmpty ? null : normalizedReply, + ); + return; + } await _ircService.sendPrivmsg( target: activeTab.name, text: text, replyTo: normalizedReply.isEmpty ? null : normalizedReply, ); + _playSound(SoundEvent.send); if (!_ircService.enabledCapabilities.contains('echo-message')) { _appendMessage( tabId: activeTab.id, @@ -1305,8 +1371,14 @@ class ChatSessionController extends ChangeNotifier { await _startOutgoingDccSend(nick: normalizedNick, filePath: normalizedPath); } - Future closeActiveDccSession() async { - final session = activeDccSession; + Future closeActiveDccSession() { + return closeDccSessionByTab(activeTabId); + } + + /// Closes the DCC session bound to [tabId] (used by the transfers modal, + /// which can act on any session, not just the active tab's). + Future closeDccSessionByTab(String tabId) async { + final session = _dccSessions[tabId]; if (session == null) { return; } @@ -1967,6 +2039,8 @@ class ChatSessionController extends ChangeNotifier { Future reloadSettings() async { _settings = await _settingsRepository.loadSettings(); + // Pick up alias edits made in Settings → Command aliases. + await _commandService.load(); _applySettingsToServices(); notifyListeners(); } @@ -2041,6 +2115,10 @@ class ChatSessionController extends ChangeNotifier { } if (snapshot.phase == ConnectionPhase.connected) { + if (_lastSoundedPhase != ConnectionPhase.connected) { + _lastSoundedPhase = ConnectionPhase.connected; + _playSound(SoundEvent.login); + } _autoHistoryRequestedChannels.clear(); _reconnectAttempt = 0; _pendingReconnectDelay = null; @@ -2055,6 +2133,12 @@ class ChatSessionController extends ChangeNotifier { if (snapshot.phase == ConnectionPhase.error || snapshot.phase == ConnectionPhase.disconnected) { + // Only beep on the drop from an established connection, not on every + // failed reconnect attempt. + if (_lastSoundedPhase == ConnectionPhase.connected) { + _lastSoundedPhase = snapshot.phase; + _playSound(SoundEvent.disconnect); + } _autoHistoryRequestedChannels.clear(); _autoJoinAttempted = false; _serviceAuthFallbackAttempted = false; @@ -2062,9 +2146,92 @@ class ChatSessionController extends ChangeNotifier { } } + /// Fire-and-forget event sound; failures never affect message handling. + void _playSound(SoundEvent event) { + final service = _soundService; + if (service == null) { + return; + } + unawaited(service.playEvent(event)); + } + Future _runPostRegistrationActions() async { await _sendServiceAuthFallbackIfNeeded(); await _autoJoinConfiguredChannels(); + await _flushOfflineOutbox(); + } + + /// Messages typed while disconnected, sent in order after the next + /// successful registration (after auto-join, so channel sends follow the + /// JOIN on the wire). + final List<({String tabId, String target, String text, String? replyTo})> + _offlineOutbox = []; + static const int _maxOfflineOutbox = 50; + + int get queuedOfflineMessages => _offlineOutbox.length; + + void _queueOfflineMessage({ + required String tabId, + required String target, + required String text, + String? replyTo, + }) { + if (_offlineOutbox.length >= _maxOfflineOutbox) { + _appendMessage( + tabId: tabId, + sender: 'error', + content: 'Offline queue is full; message dropped.', + kind: IrcMessageKind.system, + ); + notifyListeners(); + return; + } + _offlineOutbox.add(( + tabId: tabId, + target: target, + text: text, + replyTo: replyTo, + )); + _appendMessage( + tabId: tabId, + sender: '*', + content: 'Not connected — message queued and will be sent on reconnect.', + kind: IrcMessageKind.system, + ); + notifyListeners(); + } + + Future _flushOfflineOutbox() async { + if (_offlineOutbox.isEmpty) { + return; + } + final pending = List.of(_offlineOutbox); + _offlineOutbox.clear(); + for (final item in pending) { + if (_connection.phase != ConnectionPhase.connected) { + // Connection dropped mid-flush: requeue the rest for the next cycle. + _offlineOutbox.add(item); + continue; + } + await _ircService.sendPrivmsg( + target: item.target, + text: item.text, + replyTo: item.replyTo, + ); + if (!_ircService.enabledCapabilities.contains('echo-message')) { + _appendMessage( + tabId: item.tabId, + sender: _ircService.currentNick ?? network.nickname, + content: item.text, + tags: { + if ((item.replyTo ?? '').isNotEmpty) 'draft/reply': item.replyTo!, + }, + isOwn: true, + ); + } + } + unawaited(_persistState()); + notifyListeners(); } Future _sendServiceAuthFallbackIfNeeded() async { @@ -4674,6 +4841,7 @@ class ChatSessionController extends ChangeNotifier { if (nick == (_ircService.currentNick ?? network.nickname)) { _activeTabId = tab.id; } else { + _playSound(SoundEvent.join); _maybeApplyAutoModes( channel, nick, @@ -4720,6 +4888,9 @@ class ChatSessionController extends ChangeNotifier { '$kickedNick was kicked from $channel by ${frame.senderNick ?? '*'}${frame.trailing == null ? '' : ' (${frame.trailing})'}', kind: IrcMessageKind.system, ); + if (_isSelfNick(kickedNick)) { + _playSound(SoundEvent.kick); + } if (_isSelfNick(kickedNick) && _settings.autoRejoinOnKick && _connection.phase == ConnectionPhase.connected) { @@ -5744,6 +5915,7 @@ class ChatSessionController extends ChangeNotifier { kind: IrcMessageKind.system, ); _markActivityIfInactive(tabId); + _playSound(SoundEvent.ctcp); unawaited(_respondToCtcpRequest(senderNick, command, ctcp.args)); } @@ -6382,6 +6554,35 @@ class ChatSessionController extends ChangeNotifier { return appended; } + /// Per-tab notification override for [target] (channel or query name). + ChannelNotificationRule notificationRuleFor(String target) { + return _channelNotificationRules[target.toLowerCase()] ?? + ChannelNotificationRule.defaults; + } + + Future setChannelNotificationRule( + String target, + ChannelNotificationRule rule, + ) async { + final key = target.toLowerCase(); + if (rule == ChannelNotificationRule.defaults) { + _channelNotificationRules.remove(key); + } else { + _channelNotificationRules[key] = rule; + } + notifyListeners(); + await _channelNotificationRulesRepository.setRule(network.id, target, rule); + } + + Future _loadChannelNotificationRules() async { + final rules = await _channelNotificationRulesRepository.loadRules( + network.id, + ); + _channelNotificationRules + ..clear() + ..addAll(rules); + } + void _emitIncomingMessageNotification(IrcMessage? message) { if (message == null || message.isOwn || message.isPlayback) { return; @@ -6396,10 +6597,39 @@ class ChatSessionController extends ChangeNotifier { return; } - final channelKind = _notificationKindForMessage(message, tab); + final rule = + (tab.type == ChatTabType.channel || tab.type == ChatTabType.query) + ? notificationRuleFor(tab.name) + : ChannelNotificationRule.defaults; + if (rule == ChannelNotificationRule.mute) { + return; + } + + var channelKind = _notificationKindForMessage(message, tab); + // "All messages" promotes regular channel chat to a highlight-channel + // notification; by default channels only notify on highlights/media. + if (channelKind == null && + rule == ChannelNotificationRule.all && + tab.type == ChatTabType.channel && + (message.kind == IrcMessageKind.chat || + message.kind == IrcMessageKind.action)) { + channelKind = ForegroundNotificationChannelKind.highlights; + } if (channelKind == null) { return; } + if (rule == ChannelNotificationRule.mentionsOnly && + channelKind != ForegroundNotificationChannelKind.highlights) { + return; + } + + // Sounds are independent of the notification permission gating below. + _playSound(switch (channelKind) { + ForegroundNotificationChannelKind.highlights => SoundEvent.mention, + ForegroundNotificationChannelKind.dccTransfers => SoundEvent.ring, + _ when tab.type == ChatTabType.notice => SoundEvent.notice, + _ => SoundEvent.privateMessage, + }); _emitNotification( channelKind: channelKind, @@ -6420,6 +6650,7 @@ class ChatSessionController extends ChangeNotifier { if (normalizedBody.isEmpty) { return; } + _playSound(SoundEvent.fail); _emitNotification( channelKind: ForegroundNotificationChannelKind.errors, tabId: tabId, diff --git a/lib/features/chat/application/command_service.dart b/lib/features/chat/application/command_service.dart index ade957c..abdb8bb 100644 --- a/lib/features/chat/application/command_service.dart +++ b/lib/features/chat/application/command_service.dart @@ -84,6 +84,7 @@ class CommandHistoryEntry { class CommandService { static const _historyKey = 'androidircx.commandHistory'; + static const _customAliasesKey = 'androidircx.commandAliases'; static const _maxHistory = 50; static const Map serviceAliases = { @@ -792,24 +793,33 @@ class CommandService { for (final command in _defaultCommands) command.name: command, }; + /// Built-in shortcuts; user-defined aliases can shadow but not delete them. + static const Map _defaultAliasCommands = { + 'j': '/join', + 'p': '/part', + 'q': '/quit', + 'w': '/whois', + 'n': '/nick', + 'm': '/msg', + 'a': '/me', + 'k': '/kick', + 'kb': '/kickban', + 'ns': '/nickserv', + 'cs': '/chanserv', + 'hs': '/hostserv', + 'os': '/operserv', + 'ms': '/memoserv', + 'bs': '/botserv', + }; + final Map _aliases = { - 'j': const CommandAlias(alias: 'j', command: '/join'), - 'p': const CommandAlias(alias: 'p', command: '/part'), - 'q': const CommandAlias(alias: 'q', command: '/quit'), - 'w': const CommandAlias(alias: 'w', command: '/whois'), - 'n': const CommandAlias(alias: 'n', command: '/nick'), - 'm': const CommandAlias(alias: 'm', command: '/msg'), - 'a': const CommandAlias(alias: 'a', command: '/me'), - 'k': const CommandAlias(alias: 'k', command: '/kick'), - 'kb': const CommandAlias(alias: 'kb', command: '/kickban'), - 'ns': const CommandAlias(alias: 'ns', command: '/nickserv'), - 'cs': const CommandAlias(alias: 'cs', command: '/chanserv'), - 'hs': const CommandAlias(alias: 'hs', command: '/hostserv'), - 'os': const CommandAlias(alias: 'os', command: '/operserv'), - 'ms': const CommandAlias(alias: 'ms', command: '/memoserv'), - 'bs': const CommandAlias(alias: 'bs', command: '/botserv'), + for (final entry in _defaultAliasCommands.entries) + entry.key: CommandAlias(alias: entry.key, command: entry.value), }; + /// Alias names the user defined (persisted separately from the defaults). + final Map _customAliases = {}; + List _history = const []; List get history => @@ -927,6 +937,7 @@ class CommandService { Future load() async { final prefs = await SharedPreferences.getInstance(); + _loadCustomAliases(prefs.getString(_customAliasesKey)); final raw = prefs.getString(_historyKey); if (raw == null || raw.isEmpty) { _history = const []; @@ -941,6 +952,96 @@ class CommandService { .toList(growable: false); } + void _loadCustomAliases(String? raw) { + if (raw == null || raw.isEmpty) { + return; + } + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) { + return; + } + decoded.forEach((key, value) { + if (key is! String || value is! String) { + return; + } + final alias = key.trim().toLowerCase(); + final command = value.trim(); + if (alias.isEmpty || !command.startsWith('/')) { + return; + } + _customAliases[alias] = command; + _aliases[alias] = CommandAlias(alias: alias, command: command); + }); + } catch (_) { + // Corrupt alias storage: keep the defaults. + } + } + + /// All active aliases (built-in and custom), sorted by name. + List get aliases { + final list = _aliases.values.toList() + ..sort((a, b) => a.alias.compareTo(b.alias)); + return List.unmodifiable(list); + } + + /// Whether [alias] currently comes from user configuration. + bool isCustomAlias(String alias) => + _customAliases.containsKey(alias.trim().toLowerCase()); + + /// Whether [alias] is one of the built-in defaults. + bool isBuiltInAlias(String alias) => + _defaultAliasCommands.containsKey(alias.trim().toLowerCase()); + + /// Adds or updates a user alias. Returns an error message on invalid input, + /// null on success. The alias may shadow a built-in shortcut but not an + /// actual command name. + Future setAlias(String alias, String command) async { + final normalizedAlias = alias.trim().toLowerCase().replaceFirst('/', ''); + var normalizedCommand = command.trim(); + if (normalizedAlias.isEmpty || + normalizedAlias.contains(RegExp(r'\s')) || + normalizedAlias.contains('/')) { + return 'Alias must be a single word without slashes.'; + } + if (isKnownCommand(normalizedAlias)) { + return '"/$normalizedAlias" is already a command.'; + } + if (!normalizedCommand.startsWith('/')) { + normalizedCommand = '/$normalizedCommand'; + } + if (normalizedCommand.length < 2) { + return 'Alias target must be a command, e.g. /join #channel.'; + } + _customAliases[normalizedAlias] = normalizedCommand; + _aliases[normalizedAlias] = CommandAlias( + alias: normalizedAlias, + command: normalizedCommand, + ); + await _persistCustomAliases(); + return null; + } + + /// Removes a user alias; built-in defaults reappear when unshadowed. + Future removeAlias(String alias) async { + final normalized = alias.trim().toLowerCase(); + if (_customAliases.remove(normalized) == null) { + return; + } + final builtIn = _defaultAliasCommands[normalized]; + if (builtIn != null) { + _aliases[normalized] = CommandAlias(alias: normalized, command: builtIn); + } else { + _aliases.remove(normalized); + } + await _persistCustomAliases(); + } + + Future _persistCustomAliases() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_customAliasesKey, jsonEncode(_customAliases)); + } + Future addToHistory(String command) async { final now = DateTime.now(); final entry = CommandHistoryEntry( diff --git a/lib/features/chat/data/channel_notes_repository.dart b/lib/features/chat/data/channel_notes_repository.dart index 4f56fd6..c5a0db2 100644 --- a/lib/features/chat/data/channel_notes_repository.dart +++ b/lib/features/chat/data/channel_notes_repository.dart @@ -13,8 +13,7 @@ class ChannelNotesRepository { final Future Function() _prefsLoader; - String _compositeKey(String network, String channel) => - '$network::$channel'; + String _compositeKey(String network, String channel) => '$network::$channel'; Future> _readAll(SharedPreferences prefs) async { final raw = prefs.getString(storageKey); @@ -24,9 +23,7 @@ class ChannelNotesRepository { try { final decoded = jsonDecode(raw); if (decoded is Map) { - return decoded.map( - (key, value) => MapEntry('$key', '${value ?? ''}'), - ); + return decoded.map((key, value) => MapEntry('$key', '${value ?? ''}')); } } catch (_) { // Corrupt blob: start fresh rather than throw. diff --git a/lib/features/chat/data/channel_notification_rules_repository.dart b/lib/features/chat/data/channel_notification_rules_repository.dart new file mode 100644 index 0000000..95cfd5a --- /dev/null +++ b/lib/features/chat/data/channel_notification_rules_repository.dart @@ -0,0 +1,93 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +/// Per-channel/query notification override. +enum ChannelNotificationRule { + /// Follow the global notification settings (highlights, PMs, ...). + defaults, + + /// Notify for every message in this tab, not just highlights. + all, + + /// Only notify when the message is a highlight/mention. + mentionsOnly, + + /// Never notify (and never play a sound) for this tab. + mute, +} + +String channelNotificationRuleLabel(ChannelNotificationRule rule) { + return switch (rule) { + ChannelNotificationRule.defaults => 'Default', + ChannelNotificationRule.all => 'All messages', + ChannelNotificationRule.mentionsOnly => 'Mentions only', + ChannelNotificationRule.mute => 'Muted', + }; +} + +/// Persists per-channel notification rules keyed by network + target name. +class ChannelNotificationRulesRepository { + static const _key = 'androidircx.channelNotificationRules'; + + static String _entryKey(String networkId, String target) => + '$networkId|${target.toLowerCase()}'; + + Future> loadRules( + String networkId, + ) async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_key); + if (raw == null || raw.isEmpty) { + return const {}; + } + try { + final decoded = jsonDecode(raw); + if (decoded is! Map) { + return const {}; + } + final prefix = '$networkId|'; + final rules = {}; + decoded.forEach((key, value) { + if (key is! String || value is! String || !key.startsWith(prefix)) { + return; + } + for (final rule in ChannelNotificationRule.values) { + if (rule.name == value) { + rules[key.substring(prefix.length)] = rule; + } + } + }); + return rules; + } catch (_) { + return const {}; + } + } + + Future setRule( + String networkId, + String target, + ChannelNotificationRule rule, + ) async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_key); + Map stored = {}; + if (raw != null && raw.isNotEmpty) { + try { + final decoded = jsonDecode(raw); + if (decoded is Map) { + stored = Map.from(decoded); + } + } catch (_) { + // Corrupt storage: start over. + } + } + final entryKey = _entryKey(networkId, target); + if (rule == ChannelNotificationRule.defaults) { + stored.remove(entryKey); + } else { + stored[entryKey] = rule.name; + } + await prefs.setString(_key, jsonEncode(stored)); + } +} diff --git a/lib/features/chat/data/history_database.dart b/lib/features/chat/data/history_database.dart index 0ae6a1f..66ed6fe 100644 --- a/lib/features/chat/data/history_database.dart +++ b/lib/features/chat/data/history_database.dart @@ -76,20 +76,25 @@ class DriftMessageHistoryRepository implements MessageHistoryRepository { : message.copyWith(networkId: networkId); final dedupeId = _dedupeId(stored); if (dedupeId != null) { - final existing = await (_db.select(_db.messages) - ..where((row) => - row.networkId.equals(networkId) & - row.tabId.equals(stored.tabId) & - row.msgid.equals(dedupeId)) - ..limit(1)) - .get(); + final existing = + await (_db.select(_db.messages) + ..where( + (row) => + row.networkId.equals(networkId) & + row.tabId.equals(stored.tabId) & + row.msgid.equals(dedupeId), + ) + ..limit(1)) + .get(); if (existing.isNotEmpty) { return; } } final payload = await _codec.encrypt(jsonEncode(stored.toJson())); - await _db.into(_db.messages).insert( + await _db + .into(_db.messages) + .insert( MessagesCompanion.insert( networkId: networkId, tabId: stored.tabId, @@ -122,10 +127,12 @@ class DriftMessageHistoryRepository implements MessageHistoryRepository { final anchor = (beforeMessageId ?? '').trim(); int? beforeRowId; if (anchor.isNotEmpty) { - final rows = await (_db.select(_db.messages) - ..where((row) => - row.networkId.equals(networkId) & row.tabId.equals(tabId))) - .get(); + final rows = + await (_db.select(_db.messages)..where( + (row) => + row.networkId.equals(networkId) & row.tabId.equals(tabId), + )) + .get(); for (final row in rows) { final message = await _fromRow(row); if (message.id == anchor || message.tags['msgid'] == anchor) { @@ -137,8 +144,7 @@ class DriftMessageHistoryRepository implements MessageHistoryRepository { final query = _db.select(_db.messages) ..where((row) { - final base = - row.networkId.equals(networkId) & row.tabId.equals(tabId); + final base = row.networkId.equals(networkId) & row.tabId.equals(tabId); return beforeRowId == null ? base : base & row.rowId.isSmallerThanValue(beforeRowId); @@ -165,8 +171,10 @@ class DriftMessageHistoryRepository implements MessageHistoryRepository { int limit = 100, }) async { final normalizedLimit = limit.clamp(1, 10000); - final normalizedQuery = - formatIrcPlainText(query, collapseWhitespace: true).toLowerCase(); + final normalizedQuery = formatIrcPlainText( + query, + collapseWhitespace: true, + ).toLowerCase(); final select = _db.select(_db.messages) ..where((row) { @@ -175,16 +183,16 @@ class DriftMessageHistoryRepository implements MessageHistoryRepository { condition = condition & row.tabId.equals(tabId); } if (kinds.isNotEmpty) { - condition = - condition & row.kind.isIn(kinds.map((kind) => kind.name)); + condition = condition & row.kind.isIn(kinds.map((kind) => kind.name)); } if (from != null) { - condition = condition & - row.timestampMs - .isBiggerOrEqualValue(from.millisecondsSinceEpoch); + condition = + condition & + row.timestampMs.isBiggerOrEqualValue(from.millisecondsSinceEpoch); } if (to != null) { - condition = condition & + condition = + condition & row.timestampMs.isSmallerOrEqualValue(to.millisecondsSinceEpoch); } return condition; @@ -237,17 +245,17 @@ class DriftMessageHistoryRepository implements MessageHistoryRepository { DateTime? deleteBefore, }) async { if (deleteBefore != null) { - await (_db.delete(_db.messages) - ..where((row) { - var condition = row.networkId.equals(networkId) & - row.timestampMs.isSmallerThanValue( - deleteBefore.millisecondsSinceEpoch, - ); - if (tabId != null) { - condition = condition & row.tabId.equals(tabId); - } - return condition; - })) + await (_db.delete(_db.messages)..where((row) { + var condition = + row.networkId.equals(networkId) & + row.timestampMs.isSmallerThanValue( + deleteBefore.millisecondsSinceEpoch, + ); + if (tabId != null) { + condition = condition & row.tabId.equals(tabId); + } + return condition; + })) .go(); } @@ -256,29 +264,33 @@ class DriftMessageHistoryRepository implements MessageHistoryRepository { if (tabId != null) { tabIds.add(tabId); } else { - final rows = await (_db.selectOnly(_db.messages, distinct: true) - ..addColumns([_db.messages.tabId]) - ..where(_db.messages.networkId.equals(networkId))) - .get(); + final rows = + await (_db.selectOnly(_db.messages, distinct: true) + ..addColumns([_db.messages.tabId]) + ..where(_db.messages.networkId.equals(networkId))) + .get(); for (final row in rows) { tabIds.add(row.read(_db.messages.tabId)!); } } for (final id in tabIds) { - final rowIds = await (_db.select(_db.messages) - ..where((row) => - row.networkId.equals(networkId) & row.tabId.equals(id)) - ..orderBy([(row) => OrderingTerm.asc(row.rowId)])) - .map((row) => row.rowId) - .get(); + final rowIds = + await (_db.select(_db.messages) + ..where( + (row) => + row.networkId.equals(networkId) & row.tabId.equals(id), + ) + ..orderBy([(row) => OrderingTerm.asc(row.rowId)])) + .map((row) => row.rowId) + .get(); if (rowIds.length <= maxMessages) { continue; } final removable = rowIds.sublist(0, rowIds.length - maxMessages); - await (_db.delete(_db.messages) - ..where((row) => row.rowId.isIn(removable))) - .go(); + await (_db.delete( + _db.messages, + )..where((row) => row.rowId.isIn(removable))).go(); } } } @@ -288,17 +300,17 @@ class DriftMessageHistoryRepository implements MessageHistoryRepository { required String networkId, required String tabId, }) async { - await (_db.delete(_db.messages) - ..where((row) => - row.networkId.equals(networkId) & row.tabId.equals(tabId))) + await (_db.delete(_db.messages)..where( + (row) => row.networkId.equals(networkId) & row.tabId.equals(tabId), + )) .go(); } @override Future deleteNetworkHistory(String networkId) async { - await (_db.delete(_db.messages) - ..where((row) => row.networkId.equals(networkId))) - .go(); + await (_db.delete( + _db.messages, + )..where((row) => row.networkId.equals(networkId))).go(); } Future _fromRow(Message row) async { diff --git a/lib/features/chat/data/history_payload_cipher.dart b/lib/features/chat/data/history_payload_cipher.dart index 56cd717..a2ed512 100644 --- a/lib/features/chat/data/history_payload_cipher.dart +++ b/lib/features/chat/data/history_payload_cipher.dart @@ -10,7 +10,7 @@ import 'package:cryptography/cryptography.dart'; /// nonce, the ciphertext, and the GCM authentication tag, all base64-encoded. class AesGcmHistoryPayloadCodec implements HistoryPayloadCodec { AesGcmHistoryPayloadCodec(List keyBytes) - : _secretKey = SecretKey(keyBytes); + : _secretKey = SecretKey(keyBytes); factory AesGcmHistoryPayloadCodec.fromBase64Key(String base64Key) { return AesGcmHistoryPayloadCodec(base64Decode(base64Key)); diff --git a/lib/features/chat/data/kick_ban_reasons_repository.dart b/lib/features/chat/data/kick_ban_reasons_repository.dart new file mode 100644 index 0000000..46ba4a3 --- /dev/null +++ b/lib/features/chat/data/kick_ban_reasons_repository.dart @@ -0,0 +1,48 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +/// Preset kick/ban reasons offered in the moderation dialog; user-editable +/// in Settings → Channels. +class KickBanReasonsRepository { + static const _key = 'androidircx.kickBanReasons'; + + static const List defaultReasons = [ + 'Spam', + 'Flooding', + 'Abuse', + 'Off-topic', + 'Policy violation', + ]; + + Future> loadReasons() async { + final prefs = await SharedPreferences.getInstance(); + final raw = prefs.getString(_key); + if (raw == null || raw.isEmpty) { + return defaultReasons; + } + try { + final decoded = jsonDecode(raw); + if (decoded is! List) { + return defaultReasons; + } + final reasons = decoded + .whereType() + .map((reason) => reason.trim()) + .where((reason) => reason.isNotEmpty) + .toList(growable: false); + return reasons.isEmpty ? defaultReasons : reasons; + } catch (_) { + return defaultReasons; + } + } + + Future saveReasons(List reasons) async { + final prefs = await SharedPreferences.getInstance(); + final cleaned = reasons + .map((reason) => reason.trim()) + .where((reason) => reason.isNotEmpty) + .toList(growable: false); + await prefs.setString(_key, jsonEncode(cleaned)); + } +} diff --git a/lib/features/chat/data/user_notes_repository.dart b/lib/features/chat/data/user_notes_repository.dart index 9dc424e..261981d 100644 --- a/lib/features/chat/data/user_notes_repository.dart +++ b/lib/features/chat/data/user_notes_repository.dart @@ -24,9 +24,7 @@ class UserNotesRepository { try { final decoded = jsonDecode(raw); if (decoded is Map) { - return decoded.map( - (key, value) => MapEntry('$key', '${value ?? ''}'), - ); + return decoded.map((key, value) => MapEntry('$key', '${value ?? ''}')); } } catch (_) { // Corrupt blob: start fresh rather than throw. diff --git a/lib/features/chat/presentation/channel_settings_screen.dart b/lib/features/chat/presentation/channel_settings_screen.dart new file mode 100644 index 0000000..6ce2ce8 --- /dev/null +++ b/lib/features/chat/presentation/channel_settings_screen.dart @@ -0,0 +1,229 @@ +import 'dart:async'; + +import 'package:androidircx/core/models/chat_tab.dart'; +import 'package:androidircx/core/models/irc_message.dart'; +import 'package:androidircx/features/chat/application/chat_session_controller.dart'; +import 'package:androidircx/features/chat/data/channel_notification_rules_repository.dart'; +import 'package:androidircx/features/chat/data/channel_notes_repository.dart'; +import 'package:androidircx/features/connections/application/network_list_controller.dart'; +import 'package:androidircx/irc/parser/irc_formatter.dart'; +import 'package:flutter/material.dart'; + +/// Per-channel settings: topic overview, auto-join on connect, a local +/// channel note, and the recent message log — consolidated in one screen. +class ChannelSettingsScreen extends StatefulWidget { + const ChannelSettingsScreen({ + super.key, + required this.controller, + required this.tab, + this.networkController, + this.notesRepository, + }); + + final ChatSessionController controller; + final ChatTab tab; + + /// Needed to persist the auto-join toggle; hidden when absent. + final NetworkListController? networkController; + final ChannelNotesRepository? notesRepository; + + @override + State createState() => _ChannelSettingsScreenState(); +} + +class _ChannelSettingsScreenState extends State { + late final ChannelNotesRepository _notes; + final TextEditingController _noteController = TextEditingController(); + bool _autoJoin = false; + bool _noteLoaded = false; + + @override + void initState() { + super.initState(); + _notes = widget.notesRepository ?? ChannelNotesRepository(); + _autoJoin = widget.controller.network.autoJoinChannels.any( + (channel) => channel.toLowerCase() == widget.tab.name.toLowerCase(), + ); + unawaited(_loadNote()); + } + + @override + void dispose() { + _noteController.dispose(); + super.dispose(); + } + + Future _loadNote() async { + final note = await _notes.getNote( + widget.controller.network.id, + widget.tab.name, + ); + if (!mounted) { + return; + } + setState(() { + _noteController.text = note; + _noteLoaded = true; + }); + } + + Future _saveNote() async { + await _notes.setNote( + widget.controller.network.id, + widget.tab.name, + _noteController.text, + ); + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + _noteController.text.trim().isEmpty + ? 'Channel note cleared.' + : 'Channel note saved.', + ), + ), + ); + } + + Future _toggleAutoJoin(bool value) async { + final networkController = widget.networkController; + if (networkController == null) { + return; + } + setState(() => _autoJoin = value); + await networkController.setChannelAutoJoin( + networkId: widget.controller.network.id, + channel: widget.tab.name, + autoJoin: value, + ); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final topic = widget.controller.topicForTab(widget.tab.id); + final messages = widget.controller.messagesForTab(widget.tab.id); + final recent = messages.length <= 50 + ? messages + : messages.sublist(messages.length - 50); + return Scaffold( + appBar: AppBar(title: Text(widget.tab.name)), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + if ((topic ?? '').trim().isNotEmpty) ...[ + Text('Topic', style: theme.textTheme.titleSmall), + const SizedBox(height: 4), + Text(stripIrcFormatting(topic!)), + const SizedBox(height: 16), + ], + if (widget.networkController != null) + SwitchListTile( + key: const Key('channel-settings-auto-join'), + contentPadding: EdgeInsets.zero, + title: const Text('Auto-join on connect'), + subtitle: const Text( + 'Join this channel automatically when the network connects.', + ), + value: _autoJoin, + onChanged: (value) => unawaited(_toggleAutoJoin(value)), + ), + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Notifications'), + subtitle: const Text( + 'Override the global notification rules for this channel.', + ), + trailing: DropdownButton( + key: const Key('channel-settings-notification-rule'), + value: widget.controller.notificationRuleFor(widget.tab.name), + onChanged: (value) { + if (value == null) { + return; + } + unawaited( + widget.controller.setChannelNotificationRule( + widget.tab.name, + value, + ), + ); + setState(() {}); + }, + items: ChannelNotificationRule.values + .map( + (rule) => DropdownMenuItem( + value: rule, + child: Text(channelNotificationRuleLabel(rule)), + ), + ) + .toList(growable: false), + ), + ), + const SizedBox(height: 8), + Text('Channel note', style: theme.textTheme.titleSmall), + const SizedBox(height: 4), + TextField( + key: const Key('channel-settings-note'), + controller: _noteController, + enabled: _noteLoaded, + minLines: 2, + maxLines: 5, + decoration: const InputDecoration( + hintText: 'Notes for this channel (stored only on this device)', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: FilledButton.tonal( + key: const Key('channel-settings-save-note'), + onPressed: _noteLoaded ? () => unawaited(_saveNote()) : null, + child: const Text('Save note'), + ), + ), + const SizedBox(height: 16), + Text( + 'Recent log (${recent.length})', + style: theme.textTheme.titleSmall, + ), + const SizedBox(height: 4), + if (recent.isEmpty) + const Text('No messages yet.') + else + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final message in recent) + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + _logLine(message), + style: theme.textTheme.bodySmall, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } + + String _logLine(IrcMessage message) { + final time = message.timestamp.toLocal(); + final hh = time.hour.toString().padLeft(2, '0'); + final mm = time.minute.toString().padLeft(2, '0'); + return '[$hh:$mm] ${message.sender}: ${stripIrcFormatting(message.content)}'; + } +} diff --git a/lib/features/chat/presentation/chat_screen.dart b/lib/features/chat/presentation/chat_screen.dart index 665682f..478fb7c 100644 --- a/lib/features/chat/presentation/chat_screen.dart +++ b/lib/features/chat/presentation/chat_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:androidircx/app/theme/app_theme.dart'; +import 'package:androidircx/core/models/app_settings.dart'; import 'package:androidircx/core/models/chat_tab.dart'; import 'package:androidircx/core/models/connection_state.dart'; import 'package:androidircx/core/models/dcc_session.dart'; @@ -12,12 +13,15 @@ import 'package:androidircx/features/chat/application/command_service.dart'; import 'package:androidircx/features/chat/application/chat_session_controller.dart'; import 'package:androidircx/features/chat/application/session_registry.dart'; import 'package:androidircx/features/chat/data/channel_notes_repository.dart'; +import 'package:androidircx/features/chat/data/kick_ban_reasons_repository.dart'; import 'package:androidircx/features/chat/data/user_list_entry.dart'; import 'package:androidircx/features/chat/data/user_notes_repository.dart'; import 'package:androidircx/features/connections/application/network_list_controller.dart'; import 'package:androidircx/features/chat/presentation/channel_list_screen.dart'; +import 'package:androidircx/features/chat/presentation/channel_settings_screen.dart'; import 'package:androidircx/features/chat/presentation/connection_details_screen.dart'; import 'package:androidircx/features/chat/presentation/media_player_screen.dart'; +import 'package:androidircx/features/chat/presentation/message_line_format.dart'; 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'; @@ -169,376 +173,413 @@ class _ChatScreenState extends State { .where((message) => message.kind != IrcMessageKind.event) .toList(growable: false) : baseMessages; - return Scaffold( - appBar: AppBar( - title: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(_controller.activeTab.name), - Text( - _controller.activeTab.type == ChatTabType.channel && - _controller.activeChannelSummary.isNotEmpty - ? _controller.activeChannelSummary - : _controller.activeTab.type == ChatTabType.dcc && - _controller.activeDccSession != null - ? _dccSummary(_controller.activeDccSession!) - : _statusText(_controller.connection), - style: Theme.of(context).textTheme.bodySmall, + return CallbackShortcuts( + bindings: _keyboardBindings(), + child: Scaffold( + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(_controller.activeTab.name), + Text( + _controller.activeTab.type == ChatTabType.channel && + _controller.activeChannelSummary.isNotEmpty + ? _controller.activeChannelSummary + : _controller.activeTab.type == ChatTabType.dcc && + _controller.activeDccSession != null + ? _dccSummary(_controller.activeDccSession!) + : _statusText(_controller.connection), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + actions: [ + if (_controller.activeTab.type == ChatTabType.channel) + Builder( + builder: (context) { + return IconButton( + onPressed: () => Scaffold.of(context).openEndDrawer(), + icon: const Icon(Icons.people_outline), + tooltip: 'Nick list', + ); + }, + ), + if (_controller.settings.showHeaderSearchButton) + IconButton( + onPressed: _toggleMessageSearch, + icon: Icon( + _messageSearchVisible ? Icons.search_off : Icons.search, + ), + tooltip: _messageSearchVisible + ? 'Close search' + : 'Search messages', + ), + IconButton( + onPressed: _openHistoryTools, + icon: const Icon(Icons.history), + tooltip: 'History tools', ), - ], - ), - actions: [ - if (_controller.activeTab.type == ChatTabType.channel) - Builder( - builder: (context) { - return IconButton( - onPressed: () => Scaffold.of(context).openEndDrawer(), - icon: const Icon(Icons.people_outline), - tooltip: 'Nick list', - ); - }, + IconButton( + onPressed: _showJoinDialog, + icon: const Icon(Icons.tag), + tooltip: 'Join channel', ), - if (_controller.settings.showHeaderSearchButton) IconButton( - onPressed: _toggleMessageSearch, - icon: Icon( - _messageSearchVisible ? Icons.search_off : Icons.search, + onPressed: _openChannelList, + icon: const Icon(Icons.format_list_bulleted), + tooltip: 'Channel list', + ), + if (_controller.dccSessions.isNotEmpty) + IconButton( + key: const Key('chat-dcc-transfers'), + onPressed: _openDccTransfers, + icon: Badge.count( + count: _activeDccTransferCount, + isLabelVisible: _activeDccTransferCount > 0, + child: const Icon(Icons.swap_vert_circle_outlined), + ), + tooltip: 'DCC transfers', ), - tooltip: _messageSearchVisible - ? 'Close search' - : 'Search messages', + IconButton( + onPressed: _openSettings, + icon: const Icon(Icons.tune), + tooltip: 'Settings', ), - IconButton( - onPressed: _openHistoryTools, - icon: const Icon(Icons.history), - tooltip: 'History tools', - ), - IconButton( - onPressed: _showJoinDialog, - icon: const Icon(Icons.tag), - tooltip: 'Join channel', - ), - IconButton( - onPressed: _openChannelList, - icon: const Icon(Icons.format_list_bulleted), - tooltip: 'Channel list', - ), - IconButton( - onPressed: _openSettings, - icon: const Icon(Icons.tune), - tooltip: 'Settings', - ), - IconButton( - onPressed: + IconButton( + onPressed: + _controller.connection.phase == + ConnectionPhase.connected + ? _controller.disconnect + : _controller.start, + icon: Icon( _controller.connection.phase == ConnectionPhase.connected - ? _controller.disconnect - : _controller.start, - icon: Icon( - _controller.connection.phase == ConnectionPhase.connected - ? Icons.link_off - : Icons.wifi_tethering, + ? Icons.link_off + : Icons.wifi_tethering, + ), + tooltip: + _controller.connection.phase == + ConnectionPhase.connected + ? 'Disconnect' + : 'Connect', ), - tooltip: - _controller.connection.phase == ConnectionPhase.connected - ? 'Disconnect' - : 'Connect', - ), - ], - ), - drawer: Drawer( - child: SafeArea( - child: ListView( - padding: EdgeInsets.zero, - children: [ - if (widget.onSwitchNetwork != null && - widget.networkController != null) ...[ - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 4), - child: Text( - 'NETWORKS', - style: Theme.of(context).textTheme.labelSmall - ?.copyWith( - fontWeight: FontWeight.bold, - letterSpacing: 0.8, - ), + ], + ), + drawer: Drawer( + child: SafeArea( + child: ListView( + padding: EdgeInsets.zero, + children: [ + if (widget.onSwitchNetwork != null && + widget.networkController != null) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 4), + child: Text( + 'NETWORKS', + style: Theme.of(context).textTheme.labelSmall + ?.copyWith( + fontWeight: FontWeight.bold, + letterSpacing: 0.8, + ), + ), + ), + for (final network + in widget.networkController!.networks) + _buildNetworkSwitchTile(network), + ListTile( + leading: const Icon(Icons.dns_outlined), + title: const Text('Manage networks'), + subtitle: const Text('Add, edit, or browse servers'), + onTap: () { + Navigator.of(context).pop(); + widget.onManageNetworks?.call(); + }, + ), + const Divider(height: 1), + ], + ListTile( + title: Text(_controller.network.name), + subtitle: Text( + '${_controller.network.host}:${_controller.network.port}', ), ), - for (final network in widget.networkController!.networks) - _buildNetworkSwitchTile(network), + const Divider(height: 1), + for (final tab in _controller.tabs) + _buildTabTile(context, tab), + const Divider(height: 1), ListTile( - leading: const Icon(Icons.dns_outlined), - title: const Text('Manage networks'), - subtitle: const Text('Add, edit, or browse servers'), + leading: const Icon(Icons.block), + title: const Text('Ignore list'), onTap: () { Navigator.of(context).pop(); - widget.onManageNetworks?.call(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + IgnoreListScreen(controller: _controller), + ), + ); }, ), - const Divider(height: 1), - ], - ListTile( - title: Text(_controller.network.name), - subtitle: Text( - '${_controller.network.host}:${_controller.network.port}', - ), - ), - const Divider(height: 1), - for (final tab in _controller.tabs) - _buildTabTile(context, tab), - const Divider(height: 1), - ListTile( - leading: const Icon(Icons.block), - title: const Text('Ignore list'), - onTap: () { - Navigator.of(context).pop(); - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - IgnoreListScreen(controller: _controller), - ), - ); - }, - ), - ListTile( - leading: const Icon(Icons.rule_outlined), - title: const Text('User lists'), - subtitle: const Text( - 'Notify, protected, blacklist, auto-mode', + ListTile( + leading: const Icon(Icons.rule_outlined), + title: const Text('User lists'), + subtitle: const Text( + 'Notify, protected, blacklist, auto-mode', + ), + onTap: () { + Navigator.of(context).pop(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => + UserListsScreen(controller: _controller), + ), + ); + }, ), - onTap: () { - Navigator.of(context).pop(); - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => - UserListsScreen(controller: _controller), - ), - ); - }, - ), - ListTile( - leading: const Icon(Icons.info_outline), - title: const Text('Connection details'), - onTap: () { - Navigator.of(context).pop(); - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => ConnectionDetailsScreen( - controller: _controller, + ListTile( + leading: const Icon(Icons.info_outline), + title: const Text('Connection details'), + onTap: () { + Navigator.of(context).pop(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ConnectionDetailsScreen( + controller: _controller, + ), ), - ), - ); - }, - ), - ], + ); + }, + ), + ], + ), ), ), - ), - endDrawer: _controller.activeTab.type == ChatTabType.channel - ? _buildNickListDrawer(context) - : null, - body: SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - // In short viewports (landscape, or portrait with the keyboard - // open) the chrome around the message list (status/topic - // banners on top, composer + its bars on the bottom) can be - // taller than the available height and would crush the list to - // zero. When space is tight, cap the top/bottom chrome and let - // each scroll internally so the message list always keeps room - // and nothing overflows. In normal viewports the caps are the - // full height (a no-op), so interactive banners such as the DCC - // accept/decline actions stay at their natural, hittable size. - final tight = constraints.maxHeight < 380; - final topChromeMaxHeight = tight - ? constraints.maxHeight * 0.35 - : constraints.maxHeight; - final bottomClusterMaxHeight = tight - ? constraints.maxHeight * 0.5 - : constraints.maxHeight; - return Column( - children: [ - ConstrainedBox( - constraints: BoxConstraints( - maxHeight: topChromeMaxHeight, - ), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - _ConnectionBanner( - controller: _controller, - network: _controller.network, - connectedBannerDismissed: - _connectedBannerDismissed, - onDismissConnectedBanner: () => setState( - () => _connectedBannerDismissed = true, - ), - ), - if (_messageSearchVisible) - _InlineMessageSearchBar( - controller: _messageSearchController, - filter: _messageSearchFilter, - resultCount: visibleMessages.length, - onFilterChanged: (filter) => setState( - () => _messageSearchFilter = filter, + endDrawer: _controller.activeTab.type == ChatTabType.channel + ? _buildNickListDrawer(context) + : null, + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + // In short viewports (landscape, or portrait with the keyboard + // open) the chrome around the message list (status/topic + // banners on top, composer + its bars on the bottom) can be + // taller than the available height and would crush the list to + // zero. When space is tight, cap the top/bottom chrome and let + // each scroll internally so the message list always keeps room + // and nothing overflows. In normal viewports the caps are the + // full height (a no-op), so interactive banners such as the DCC + // accept/decline actions stay at their natural, hittable size. + final tight = constraints.maxHeight < 380; + final topChromeMaxHeight = tight + ? constraints.maxHeight * 0.35 + : constraints.maxHeight; + final bottomClusterMaxHeight = tight + ? constraints.maxHeight * 0.5 + : constraints.maxHeight; + return Column( + children: [ + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: topChromeMaxHeight, + ), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _ConnectionBanner( + controller: _controller, + network: _controller.network, + connectedBannerDismissed: + _connectedBannerDismissed, + onDismissConnectedBanner: () => setState( + () => _connectedBannerDismissed = true, ), - onChanged: (_) => setState(() {}), - onClose: _toggleMessageSearch, - ), - if ((_controller.activeChannelTopic ?? '') - .trim() - .isNotEmpty) - _ChannelTopicBar( - topic: _controller.activeChannelTopic!.trim(), ), - if (_controller.activeTab.type == - ChatTabType.dcc && - _controller.activeDccSession != null) - _DccSessionBanner( - session: _controller.activeDccSession!, - onAccept: _controller.acceptActiveDccSession, - onDecline: - _controller.declineActiveDccSession, - onClose: _controller.closeActiveDccSession, - ), - if (_controller.activeTab.type == - ChatTabType.server) - _ServiceQuickActions( - onRun: (service, command) async { - await _controller.sendServiceShortcut( - service, - command, - ); - }, - ), - ], + if (_messageSearchVisible) + _InlineMessageSearchBar( + controller: _messageSearchController, + filter: _messageSearchFilter, + resultCount: visibleMessages.length, + onFilterChanged: (filter) => setState( + () => _messageSearchFilter = filter, + ), + onChanged: (_) => setState(() {}), + onClose: _toggleMessageSearch, + ), + if ((_controller.activeChannelTopic ?? '') + .trim() + .isNotEmpty) + _ChannelTopicBar( + topic: _controller.activeChannelTopic! + .trim(), + ), + if (_controller.activeTab.type == + ChatTabType.dcc && + _controller.activeDccSession != null) + _DccSessionBanner( + session: _controller.activeDccSession!, + onAccept: + _controller.acceptActiveDccSession, + onDecline: + _controller.declineActiveDccSession, + onClose: _controller.closeActiveDccSession, + ), + if (_controller.activeTab.type == + ChatTabType.server) + _ServiceQuickActions( + onRun: (service, command) async { + await _controller.sendServiceShortcut( + service, + command, + ); + }, + ), + ], + ), ), ), - ), - Expanded( - child: _MessageList( - messages: visibleMessages, - knownNicks: { - ..._controller.activeChannelUsers, - _controller.currentNick, - }, - channelPrefixes: _controller.channelPrefixChars, - nickPrefixes: _controller.nickPrefixChars, - showAttachmentPreviews: - _controller.settings.showAttachmentPreviews, - resolveReplyTarget: (replyId) => _controller - .messageByMsgId(_controller.activeTabId, replyId), - resolveReactions: _controller.reactionsForMessage, - onRedactMessage: _controller.redactMessage, - onQuoteMessage: (message) => _insertIntoComposer( - '> ${stripIrcFormatting(message.content)}', - ), - onReplyWithNick: (message) { - final prefix = - message.sender == _controller.currentNick - ? '' - : '${message.sender}: '; - _insertIntoComposer(prefix); - }, - onReplyToMessage: _setPendingReply, - onDownloadAttachment: _downloadAttachment, - onNickTap: (nick) => unawaited( - _controller.performChannelUserAction( - nick, - ChannelUserAction.query, + Expanded( + child: _MessageList( + messages: visibleMessages, + knownNicks: { + ..._controller.activeChannelUsers, + _controller.currentNick, + }, + channelPrefixes: _controller.channelPrefixChars, + nickPrefixes: _controller.nickPrefixChars, + showAttachmentPreviews: + _controller.settings.showAttachmentPreviews, + resolveReplyTarget: (replyId) => + _controller.messageByMsgId( + _controller.activeTabId, + replyId, + ), + resolveReactions: _controller.reactionsForMessage, + onReactToMessage: _controller.reactToMessage, + onRedactMessage: _controller.redactMessage, + onQuoteMessage: (message) => _insertIntoComposer( + '> ${stripIrcFormatting(message.content)}', ), - ), - onNickLongPress: (nick) => - unawaited(_showChannelUserActions(nick)), - onChannelTap: (channel) => unawaited( - _controller.joinChannel( - JoinChannelRequest(channel: channel), + onReplyWithNick: (message) { + final prefix = + message.sender == _controller.currentNick + ? '' + : '${message.sender}: '; + _insertIntoComposer(prefix); + }, + onReplyToMessage: _setPendingReply, + onDownloadAttachment: _downloadAttachment, + onNickTap: (nick) => unawaited( + _controller.performChannelUserAction( + nick, + ChannelUserAction.query, + ), + ), + onNickLongPress: (nick) => + unawaited(_showChannelUserActions(nick)), + onChannelTap: (channel) => unawaited( + _controller.joinChannel( + JoinChannelRequest(channel: channel), + ), ), + showTimestamps: _controller.settings.showTimestamps, + timestampFormat: + _controller.settings.timestampFormat, + timestampPosition: + _controller.settings.timestampPosition, + nickDisplayFormat: + _controller.settings.nickDisplayFormat, + onLoadOlder: + _controller.hasPersistentHistory && + !_messageSearchVisible + ? () async { + await _controller.loadOlderHistory( + _controller.activeTabId, + ); + } + : null, ), - showTimestamps: _controller.settings.showTimestamps, - onLoadOlder: - _controller.hasPersistentHistory && - !_messageSearchVisible - ? () async { - await _controller.loadOlderHistory( - _controller.activeTabId, - ); - } - : null, ), - ), - ConstrainedBox( - constraints: BoxConstraints( - maxHeight: bottomClusterMaxHeight, - ), - child: SingleChildScrollView( - reverse: true, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (_controller.commandHistory.isNotEmpty) - _CommandHistoryBar( - entries: _controller.commandHistory, - onSelect: (value) => setState( - () => _composerController.text = value, + ConstrainedBox( + constraints: BoxConstraints( + maxHeight: bottomClusterMaxHeight, + ), + child: SingleChildScrollView( + reverse: true, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_controller.commandHistory.isNotEmpty) + _CommandHistoryBar( + entries: _controller.commandHistory, + onSelect: (value) => setState( + () => _composerController.text = value, + ), ), - ), - if (_controller.activeTypingUsers.isNotEmpty) - _TypingIndicator( - users: _controller.activeTypingUsers, - ), - if (_pendingReplyMessage != null) - _PendingReplyBar( - message: _pendingReplyMessage!, - onCancel: () => setState( - () => _pendingReplyMessage = null, + if (_controller.activeTypingUsers.isNotEmpty) + _TypingIndicator( + users: _controller.activeTypingUsers, + ), + if (_pendingReplyMessage != null) + _PendingReplyBar( + message: _pendingReplyMessage!, + onCancel: () => setState( + () => _pendingReplyMessage = null, + ), + ), + const Divider(height: 1), + _ComposerArea( + suggestions: _composerSuggestions, + autocompleteSuggestions: + _autocompleteSuggestions, + controller: _composerController, + hintText: + _controller.activeTab.type == + ChatTabType.server + ? 'Type raw IRC or /join #channel' + : _controller.activeTab.type == + ChatTabType.dcc + ? (_controller.activeDccSession?.type == + DccSessionType.chat + ? 'Type DCC chat message' + : 'DCC SEND tabs do not accept messages') + : 'Message ${_controller.activeTab.name}', + onChanged: _handleComposerChanged, + onSubmitted: _submit, + canSendDccFile: + _controller.activeTab.type == + ChatTabType.query, + onPickDccFile: _pickAndSendDccFile, + onSuggestionSelected: + _applyComposerSuggestion, + onAutocompleteSelected: + _applyAutocompleteSuggestion, + enterToSend: _controller.settings.enterToSend, + showSendButton: + _controller.settings.showSendButton, + autocorrect: + _controller.settings.composerAutocorrect, + enableSuggestions: + _controller.settings.composerSuggestions, + capitalizeSentences: _controller + .settings + .composerCapitalizeSentences, + onCameraPhoto: () => + _captureAndSendMedia(ImageSource.camera), + onGalleryImage: () => + _captureAndSendMedia(ImageSource.gallery), + onCameraVideo: () => _captureAndSendMedia( + ImageSource.camera, + video: true, ), ), - const Divider(height: 1), - _ComposerArea( - suggestions: _composerSuggestions, - autocompleteSuggestions: - _autocompleteSuggestions, - controller: _composerController, - hintText: - _controller.activeTab.type == - ChatTabType.server - ? 'Type raw IRC or /join #channel' - : _controller.activeTab.type == - ChatTabType.dcc - ? (_controller.activeDccSession?.type == - DccSessionType.chat - ? 'Type DCC chat message' - : 'DCC SEND tabs do not accept messages') - : 'Message ${_controller.activeTab.name}', - onChanged: _handleComposerChanged, - onSubmitted: _submit, - canSendDccFile: - _controller.activeTab.type == - ChatTabType.query, - onPickDccFile: _pickAndSendDccFile, - onSuggestionSelected: _applyComposerSuggestion, - onAutocompleteSelected: - _applyAutocompleteSuggestion, - enterToSend: _controller.settings.enterToSend, - showSendButton: - _controller.settings.showSendButton, - onCameraPhoto: () => - _captureAndSendMedia(ImageSource.camera), - onGalleryImage: () => - _captureAndSendMedia(ImageSource.gallery), - onCameraVideo: () => _captureAndSendMedia( - ImageSource.camera, - video: true, - ), - ), - ], + ], + ), ), ), - ), - ], - ); - }, + ], + ); + }, + ), ), ), ); @@ -547,6 +588,40 @@ class _ChatScreenState extends State { ); } + /// Hardware keyboard shortcuts: Ctrl+Tab / Alt+arrows switch tabs, and Tab + /// accepts the first nick-completion suggestion while the panel is open. + Map _keyboardBindings() { + return { + const SingleActivator(LogicalKeyboardKey.tab, control: true): () => + _selectAdjacentTab(1), + const SingleActivator( + LogicalKeyboardKey.tab, + control: true, + shift: true, + ): () => + _selectAdjacentTab(-1), + const SingleActivator(LogicalKeyboardKey.arrowDown, alt: true): () => + _selectAdjacentTab(1), + const SingleActivator(LogicalKeyboardKey.arrowUp, alt: true): () => + _selectAdjacentTab(-1), + if (_autocompleteSuggestions.isNotEmpty) + const SingleActivator(LogicalKeyboardKey.tab): () => + _applyAutocompleteSuggestion(_autocompleteSuggestions.first), + }; + } + + void _selectAdjacentTab(int delta) { + final tabs = _controller.tabs; + if (tabs.length < 2) { + return; + } + final currentIndex = tabs.indexWhere( + (tab) => tab.id == _controller.activeTabId, + ); + final nextIndex = (currentIndex + delta + tabs.length) % tabs.length; + _controller.selectTab(tabs[nextIndex].id); + } + Future _showJoinDialog() async { final result = await showDialog( context: context, @@ -929,6 +1004,27 @@ class _ChatScreenState extends State { ); } + /// Sessions still doing work (pending offers or live connections). + int get _activeDccTransferCount => _controller.dccSessions + .where( + (session) => switch (session.status) { + DccSessionStatus.pending || + DccSessionStatus.offering || + DccSessionStatus.connecting || + DccSessionStatus.connected => true, + DccSessionStatus.closed || DccSessionStatus.failed => false, + }, + ) + .length; + + Future _openDccTransfers() async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => _DccTransfersSheet(controller: _controller), + ); + } + void _toggleMessageSearch() { setState(() { if (_messageSearchVisible) { @@ -969,7 +1065,16 @@ class _ChatScreenState extends State { case ConnectionPhase.authenticating: return snapshot.message ?? 'Authenticating'; case ConnectionPhase.connected: - return 'Connected'; + final lag = _controller.lag; + if (lag == null) { + return 'Connected'; + } + final quality = lag.inMilliseconds < 150 + ? 'good' + : lag.inMilliseconds < 500 + ? 'ok' + : 'slow'; + return 'Connected • ${lag.inMilliseconds} ms ($quality)'; case ConnectionPhase.reconnecting: return snapshot.message ?? 'Reconnecting'; case ConnectionPhase.disconnecting: @@ -1192,6 +1297,9 @@ class _ChatScreenState extends State { unawaited(_showTabLog(tab)); }), if (tab.type == ChatTabType.channel) ...[ + action('Channel settings', Icons.settings_outlined, () { + unawaited(_openChannelSettings(tab)); + }), action('Channel note', Icons.sticky_note_2_outlined, () { unawaited(_showChannelNoteDialog(tab)); }), @@ -1224,6 +1332,19 @@ class _ChatScreenState extends State { ); } + Future _openChannelSettings(ChatTab tab) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChannelSettingsScreen( + controller: _controller, + tab: tab, + networkController: widget.networkController, + notesRepository: _channelNotesRepository, + ), + ), + ); + } + Future _showTabLog(ChatTab tab) async { final messages = _controller.messagesForTab(tab.id); await showDialog( @@ -1438,12 +1559,17 @@ class _ChatScreenState extends State { String nick, ChannelModerationAction action, ) async { + final presetReasons = await KickBanReasonsRepository().loadReasons(); + if (!mounted) { + return; + } final request = await showDialog<_KickBanRequest>( context: context, builder: (context) => _KickBanDialog( nick: nick, action: action, maskPreview: (type) => _controller.banMaskPreviewForNick(nick, type), + presetReasons: presetReasons, ), ); if (request == null) { @@ -1595,13 +1721,6 @@ bool _isActiveDccTransfer(DccSession session) { session.status == DccSessionStatus.connected; } -String _formatClock(DateTime timestamp) { - final local = timestamp.toLocal(); - final hh = local.hour.toString().padLeft(2, '0'); - final mm = local.minute.toString().padLeft(2, '0'); - return '$hh:$mm'; -} - class _ComposerArea extends StatelessWidget { const _ComposerArea({ required this.suggestions, @@ -1616,6 +1735,9 @@ class _ComposerArea extends StatelessWidget { required this.onAutocompleteSelected, required this.enterToSend, required this.showSendButton, + this.autocorrect = true, + this.enableSuggestions = true, + this.capitalizeSentences = false, required this.onCameraPhoto, required this.onGalleryImage, required this.onCameraVideo, @@ -1633,6 +1755,9 @@ class _ComposerArea extends StatelessWidget { final ValueChanged onAutocompleteSelected; final bool enterToSend; final bool showSendButton; + final bool autocorrect; + final bool enableSuggestions; + final bool capitalizeSentences; final VoidCallback onCameraPhoto; final VoidCallback onGalleryImage; final VoidCallback onCameraVideo; @@ -1666,6 +1791,11 @@ class _ComposerArea extends StatelessWidget { textInputAction: enterToSend ? TextInputAction.send : TextInputAction.newline, + autocorrect: autocorrect, + enableSuggestions: enableSuggestions, + textCapitalization: capitalizeSentences + ? TextCapitalization.sentences + : TextCapitalization.none, onChanged: onChanged, onSubmitted: enterToSend ? (_) => onSubmitted() : null, decoration: InputDecoration(hintText: hintText), @@ -2081,6 +2211,134 @@ class _DccSessionBanner extends StatelessWidget { } } +/// Bottom sheet listing every DCC session of this network's controller with +/// live progress and per-session actions. +class _DccTransfersSheet extends StatelessWidget { + const _DccTransfersSheet({required this.controller}); + + final ChatSessionController controller; + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: controller, + builder: (context, _) { + final sessions = controller.dccSessions; + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'DCC transfers', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + if (sessions.isEmpty) + const Padding( + padding: EdgeInsets.symmetric(vertical: 24), + child: Center(child: Text('No DCC sessions.')), + ) + else + Flexible( + child: ListView.separated( + shrinkWrap: true, + itemCount: sessions.length, + separatorBuilder: (_, _) => const Divider(height: 1), + itemBuilder: (context, index) => _DccTransferRow( + session: sessions[index], + controller: controller, + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class _DccTransferRow extends StatelessWidget { + const _DccTransferRow({required this.session, required this.controller}); + + final DccSession session; + final ChatSessionController controller; + + @override + Widget build(BuildContext context) { + final isDone = + session.status == DccSessionStatus.closed || + session.status == DccSessionStatus.failed; + final size = session.size; + final progress = (size != null && size > 0) + ? (session.bytesTransferred / size).clamp(0.0, 1.0) + : null; + final title = switch (session.type) { + DccSessionType.chat => 'CHAT with ${session.peerNick}', + DccSessionType.send => + '${session.direction == 'incoming' ? '⬇' : '⬆'} ' + '${session.filename ?? 'file'} • ${session.peerNick}', + DccSessionType.unknown => 'DCC • ${session.peerNick}', + }; + final subtitle = session.type == DccSessionType.send + ? _dccTransferSubtitle(session) + : 'Status: ${session.status.name}' + '${session.error == null ? '' : ' • ${session.error}'}'; + return ListTile( + key: Key('dcc-transfer-row-${session.tabId}'), + contentPadding: EdgeInsets.zero, + leading: Icon(switch (session.status) { + DccSessionStatus.failed => Icons.error_outline, + DccSessionStatus.closed => Icons.check_circle_outline, + _ => + session.type == DccSessionType.chat + ? Icons.chat_outlined + : Icons.swap_vert, + }), + title: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(subtitle, maxLines: 2, overflow: TextOverflow.ellipsis), + if (progress != null && !isDone) ...[ + const SizedBox(height: 4), + LinearProgressIndicator(value: progress), + ], + ], + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + key: Key('dcc-transfer-open-${session.tabId}'), + tooltip: 'Open tab', + icon: const Icon(Icons.open_in_new), + onPressed: () { + controller.selectTab(session.tabId); + Navigator.of(context).pop(); + }, + ), + if (!isDone) + IconButton( + key: Key('dcc-transfer-close-${session.tabId}'), + tooltip: 'Cancel', + icon: const Icon(Icons.close), + onPressed: () => controller.closeDccSessionByTab(session.tabId), + ), + ], + ), + onTap: () { + controller.selectTab(session.tabId); + Navigator.of(context).pop(); + }, + ); + } +} + class _ReplyPreview extends StatelessWidget { const _ReplyPreview({required this.referenced, required this.replyId}); @@ -2392,24 +2650,22 @@ class _KickBanDialog extends StatefulWidget { required this.nick, required this.action, required this.maskPreview, + this.presetReasons = KickBanReasonsRepository.defaultReasons, }); final String nick; final ChannelModerationAction action; final String Function(int type) maskPreview; + final List presetReasons; @override State<_KickBanDialog> createState() => _KickBanDialogState(); } class _KickBanDialogState extends State<_KickBanDialog> { - static const List _presetReasons = [ + late final List _presetReasons = [ '', - 'Spam', - 'Flooding', - 'Abuse', - 'Off-topic', - 'Policy violation', + ...widget.presetReasons, ]; final TextEditingController _reason = TextEditingController(); @@ -3009,6 +3265,7 @@ class _MessageList extends StatelessWidget { required this.showAttachmentPreviews, required this.resolveReplyTarget, required this.resolveReactions, + required this.onReactToMessage, required this.onRedactMessage, required this.onQuoteMessage, required this.onReplyWithNick, @@ -3019,6 +3276,9 @@ class _MessageList extends StatelessWidget { required this.onChannelTap, this.onLoadOlder, this.showTimestamps = true, + this.timestampFormat = 'HH:mm', + this.timestampPosition = TimestampPosition.afterNick, + this.nickDisplayFormat = NickDisplayFormat.plain, }); final List messages; @@ -3027,9 +3287,14 @@ class _MessageList extends StatelessWidget { final String nickPrefixes; final bool showAttachmentPreviews; final bool showTimestamps; + final String timestampFormat; + final TimestampPosition timestampPosition; + final NickDisplayFormat nickDisplayFormat; final Future Function()? onLoadOlder; final IrcMessage? Function(String replyId) resolveReplyTarget; final Map Function(IrcMessage message) resolveReactions; + final Future Function(IrcMessage message, String emoji) + onReactToMessage; final Future Function(IrcMessage message) onRedactMessage; final ValueChanged onQuoteMessage; final ValueChanged onReplyWithNick; @@ -3119,7 +3384,15 @@ class _MessageList extends StatelessWidget { ircTheme.nickColorFor(message.sender) ?? Theme.of(context).colorScheme.onSurface, ); + final nickLabel = formatNickLabel(message.sender, nickDisplayFormat); + final clockText = formatIrcTimestamp( + message.timestamp, + timestampFormat, + ); final leadingSpans = [ + if (showTimestamps && + timestampPosition == TimestampPosition.beforeNick) + TextSpan(text: '$clockText ', style: metaStyle), if (_isInteractiveSender(message)) WidgetSpan( alignment: PlaceholderAlignment.baseline, @@ -3129,17 +3402,15 @@ class _MessageList extends StatelessWidget { onTap: () => onNickTap(message.sender), onLongPress: () => onNickLongPress(message.sender), child: RichText( - text: TextSpan(text: message.sender, style: senderStyle), + text: TextSpan(text: nickLabel, style: senderStyle), ), ), ) else - TextSpan(text: message.sender, style: senderStyle), - if (showTimestamps) - TextSpan( - text: ' ${_formatClock(message.timestamp)}', - style: metaStyle, - ), + TextSpan(text: nickLabel, style: senderStyle), + if (showTimestamps && + timestampPosition == TimestampPosition.afterNick) + TextSpan(text: ' $clockText', style: metaStyle), if (message.isPlayback) TextSpan(text: ' · history', style: metaStyle), const TextSpan(text: ' '), @@ -3221,6 +3492,8 @@ class _MessageList extends StatelessWidget { ) async { final urls = extractUrls(stripIrcFormatting(message.content)); final canRedact = (message.tags['msgid'] ?? '').trim().isNotEmpty; + final canReact = + (message.tags['msgid'] ?? '').trim().isNotEmpty && !message.isOwn; await showModalBottomSheet( context: context, builder: (context) { @@ -3232,6 +3505,52 @@ class _MessageList extends StatelessWidget { child: ListView( shrinkWrap: true, children: [ + if (canReact) + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + child: Wrap( + spacing: 4, + children: [ + for (final emoji in const [ + '👍', + '❤️', + '😂', + '😮', + '😢', + '🎉', + ]) + IconButton( + key: Key('message-react-$emoji'), + onPressed: () async { + final sent = await onReactToMessage( + message, + emoji, + ); + if (!context.mounted) { + return; + } + Navigator.of(context).pop(); + if (!sent) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'This message cannot be reacted to.', + ), + ), + ); + } + }, + icon: Text( + emoji, + style: const TextStyle(fontSize: 22), + ), + ), + ], + ), + ), ListTile( leading: const Icon(Icons.copy), title: const Text('Copy clean text'), diff --git a/lib/features/chat/presentation/join_channel_dialog.dart b/lib/features/chat/presentation/join_channel_dialog.dart index 5868d17..4a24759 100644 --- a/lib/features/chat/presentation/join_channel_dialog.dart +++ b/lib/features/chat/presentation/join_channel_dialog.dart @@ -1,9 +1,7 @@ import 'package:flutter/material.dart'; class JoinChannelRequest { - const JoinChannelRequest({ - required this.channel, - }); + const JoinChannelRequest({required this.channel}); final String channel; } @@ -46,9 +44,9 @@ class _JoinChannelDialogState extends State { ), FilledButton( onPressed: () { - Navigator.of(context).pop( - JoinChannelRequest(channel: _controller.text.trim()), - ); + Navigator.of( + context, + ).pop(JoinChannelRequest(channel: _controller.text.trim())); }, child: const Text('Join'), ), diff --git a/lib/features/chat/presentation/message_line_format.dart b/lib/features/chat/presentation/message_line_format.dart new file mode 100644 index 0000000..83eca6a --- /dev/null +++ b/lib/features/chat/presentation/message_line_format.dart @@ -0,0 +1,55 @@ +import 'package:androidircx/core/models/app_settings.dart'; + +/// Timestamp patterns offered in the message format editor. +const List<({String pattern, String label})> supportedTimestampFormats = [ + (pattern: 'HH:mm', label: '24-hour (13:05)'), + (pattern: 'HH:mm:ss', label: '24-hour with seconds (13:05:09)'), + (pattern: 'h:mm a', label: '12-hour (1:05 PM)'), + (pattern: 'h:mm:ss a', label: '12-hour with seconds (1:05:09 PM)'), +]; + +/// Formats [timestamp] (converted to local time) with one of the supported +/// patterns; unknown patterns fall back to 24-hour HH:mm. +String formatIrcTimestamp(DateTime timestamp, String pattern) { + final local = timestamp.toLocal(); + final hh24 = local.hour.toString().padLeft(2, '0'); + final mm = local.minute.toString().padLeft(2, '0'); + final ss = local.second.toString().padLeft(2, '0'); + final hour12 = switch (local.hour % 12) { + 0 => 12, + final hour => hour, + }; + final period = local.hour < 12 ? 'AM' : 'PM'; + return switch (pattern) { + 'HH:mm:ss' => '$hh24:$mm:$ss', + 'h:mm a' => '$hour12:$mm $period', + 'h:mm:ss a' => '$hour12:$mm:$ss $period', + _ => '$hh24:$mm', + }; +} + +/// Decorates a sender nick for display per the configured style. +String formatNickLabel(String nick, NickDisplayFormat format) { + return switch (format) { + NickDisplayFormat.plain => nick, + NickDisplayFormat.angle => '<$nick>', + NickDisplayFormat.colon => '$nick:', + NickDisplayFormat.bracket => '[$nick]', + }; +} + +String nickDisplayFormatLabel(NickDisplayFormat format) { + return switch (format) { + NickDisplayFormat.plain => 'nick', + NickDisplayFormat.angle => '', + NickDisplayFormat.colon => 'nick:', + NickDisplayFormat.bracket => '[nick]', + }; +} + +String timestampPositionLabel(TimestampPosition position) { + return switch (position) { + TimestampPosition.afterNick => 'After nick', + TimestampPosition.beforeNick => 'Before nick', + }; +} diff --git a/lib/features/connections/application/network_list_controller.dart b/lib/features/connections/application/network_list_controller.dart index 04c521a..67389ab 100644 --- a/lib/features/connections/application/network_list_controller.dart +++ b/lib/features/connections/application/network_list_controller.dart @@ -12,8 +12,7 @@ class NetworkListController extends ChangeNotifier { CertificateStore? certificateStore, }) : _repository = repository, _certificateStore = - certificateStore ?? - CertificateStore(FlutterSecureSecretStorage()); + certificateStore ?? CertificateStore(FlutterSecureSecretStorage()); final NetworkRepository _repository; final CertificateStore _certificateStore; @@ -62,6 +61,8 @@ class NetworkListController extends ChangeNotifier { String? clientPrivateKeyPem, String? clientPkcs12Base64, String? clientKeyPassphrase, + String encoding = 'utf-8', + bool encodingUtf8Fallback = false, String? networkId, }) async { final network = NetworkConfig( @@ -97,6 +98,8 @@ class NetworkListController extends ChangeNotifier { proxyPassword: (proxyPassword ?? '').trim().isEmpty ? null : proxyPassword, + encoding: encoding, + encodingUtf8Fallback: encodingUtf8Fallback, ); await _repository.saveNetwork(network); @@ -136,6 +139,43 @@ class NetworkListController extends ChangeNotifier { await load(); } + /// Adds or removes [channel] from a network's auto-join list without + /// touching any other config (used by the per-channel settings screen). + Future setChannelAutoJoin({ + required String networkId, + required String channel, + required bool autoJoin, + }) async { + final networks = await _repository.loadNetworks(); + NetworkConfig? network; + for (final item in networks) { + if (item.id == networkId) { + network = item; + break; + } + } + if (network == null) { + return; + } + final normalized = channel.startsWith('#') ? channel : '#$channel'; + final channels = [...network.autoJoinChannels]; + final already = channels.any( + (item) => item.toLowerCase() == normalized.toLowerCase(), + ); + if (autoJoin == already) { + return; + } + if (autoJoin) { + channels.add(normalized); + } else { + channels.removeWhere( + (item) => item.toLowerCase() == normalized.toLowerCase(), + ); + } + await _repository.saveNetwork(network.copyWith(autoJoinChannels: channels)); + await load(); + } + String _createId(String seed) { final normalized = seed.toLowerCase().replaceAll( RegExp(r'[^a-z0-9]+'), diff --git a/lib/features/connections/data/pem_bundle.dart b/lib/features/connections/data/pem_bundle.dart index 4a91683..238dbaf 100644 --- a/lib/features/connections/data/pem_bundle.dart +++ b/lib/features/connections/data/pem_bundle.dart @@ -23,7 +23,10 @@ class PemBundle { /// [text]. Returns an empty bundle when no PEM blocks are present (e.g. the /// file is binary DER/PKCS#12). static PemBundle parse(String text) { - final certs = _certificate.allMatches(text).map((m) => m.group(0)!).toList(); + final certs = _certificate + .allMatches(text) + .map((m) => m.group(0)!) + .toList(); final keyMatch = _privateKey.firstMatch(text); return PemBundle( certificate: certs.isEmpty ? null : certs.join('\n'), diff --git a/lib/features/connections/presentation/network_form_screen.dart b/lib/features/connections/presentation/network_form_screen.dart index 19052e9..8c3c69a 100644 --- a/lib/features/connections/presentation/network_form_screen.dart +++ b/lib/features/connections/presentation/network_form_screen.dart @@ -7,6 +7,7 @@ import 'package:androidircx/core/models/network_config.dart'; import 'package:androidircx/core/storage/identity_profile_repository.dart'; import 'package:androidircx/dcc/services/dcc_file_picker.dart'; import 'package:androidircx/features/connections/data/pem_bundle.dart'; +import 'package:androidircx/irc/encoding/irc_encoding.dart'; import 'package:flutter/material.dart'; class NetworkFormResult { @@ -40,6 +41,8 @@ class NetworkFormResult { this.clientPrivateKeyPem, this.clientPkcs12Base64, this.clientKeyPassphrase, + this.encoding = defaultIrcEncoding, + this.encodingUtf8Fallback = false, }); final String name; @@ -71,6 +74,8 @@ class NetworkFormResult { final String? clientPrivateKeyPem; final String? clientPkcs12Base64; final String? clientKeyPassphrase; + final String encoding; + final bool encodingUtf8Fallback; } class NetworkFormScreen extends StatefulWidget { @@ -121,6 +126,8 @@ class _NetworkFormScreenState extends State { late final TextEditingController _proxyUsernameController; late final TextEditingController _proxyPasswordController; late bool _useTls; + late String _encoding; + late bool _encodingUtf8Fallback; late bool _autoConnect; late SaslMechanism _saslMechanism; late ServiceAuthFallback _serviceAuthFallback; @@ -202,6 +209,8 @@ class _NetworkFormScreenState extends State { _serviceAuthFallback = initial?.serviceAuthFallback ?? ServiceAuthFallback.disabled; _proxyType = initial?.proxyType ?? IrcProxyType.none; + _encoding = normalizeIrcEncoding(initial?.encoding); + _encodingUtf8Fallback = initial?.encodingUtf8Fallback ?? false; } Future _loadProfiles() async { @@ -590,6 +599,43 @@ class _NetworkFormScreenState extends State { value: _useTls, onChanged: (value) => setState(() => _useTls = value), ), + const SizedBox(height: 8), + DropdownButtonFormField( + key: const Key('network-form-encoding'), + initialValue: _encoding, + decoration: const InputDecoration( + labelText: 'Text encoding', + helperText: + 'UTF-8 works for modern networks; pick a legacy ' + 'charset only if text shows garbled characters.', + ), + items: [ + for (final option in supportedIrcEncodings) + DropdownMenuItem( + value: option.label, + child: Text(option.name), + ), + ], + onChanged: (value) { + if (value == null) { + return; + } + setState(() => _encoding = value); + }, + ), + if (_encoding != defaultIrcEncoding) + SwitchListTile( + key: const Key('network-form-encoding-utf8-fallback'), + contentPadding: EdgeInsets.zero, + title: const Text('Prefer UTF-8 (fallback to encoding)'), + subtitle: const Text( + 'Decode UTF-8 lines normally and use the legacy ' + 'charset only for non-UTF-8 lines. Sends UTF-8.', + ), + value: _encodingUtf8Fallback, + onChanged: (value) => + setState(() => _encodingUtf8Fallback = value), + ), SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text('Auto connect'), @@ -785,6 +831,9 @@ class _NetworkFormScreenState extends State { clientPrivateKeyPem: _clientKeyController.text, clientPkcs12Base64: _clientPkcs12Base64, clientKeyPassphrase: _clientKeyPassphraseController.text, + encoding: _encoding, + encodingUtf8Fallback: + _encoding != defaultIrcEncoding && _encodingUtf8Fallback, ), ); } diff --git a/lib/features/connections/presentation/network_list_screen.dart b/lib/features/connections/presentation/network_list_screen.dart index 53dccdb..915dc30 100644 --- a/lib/features/connections/presentation/network_list_screen.dart +++ b/lib/features/connections/presentation/network_list_screen.dart @@ -159,6 +159,8 @@ class NetworkListScreen extends StatelessWidget { clientPrivateKeyPem: result.clientPrivateKeyPem, clientPkcs12Base64: result.clientPkcs12Base64, clientKeyPassphrase: result.clientKeyPassphrase, + encoding: result.encoding, + encodingUtf8Fallback: result.encodingUtf8Fallback, networkId: initialValue?.id, ); } diff --git a/lib/features/connections/presentation/profiles_screen.dart b/lib/features/connections/presentation/profiles_screen.dart index 51c1b25..edb5807 100644 --- a/lib/features/connections/presentation/profiles_screen.dart +++ b/lib/features/connections/presentation/profiles_screen.dart @@ -165,8 +165,8 @@ class _ProfileEditorDialogState extends State<_ProfileEditorDialog> { return; } final existing = widget.profile; - final id = existing?.id ?? - 'profile-${DateTime.now().microsecondsSinceEpoch}'; + final id = + existing?.id ?? 'profile-${DateTime.now().microsecondsSinceEpoch}'; Navigator.of(context).pop( IdentityProfile( id: id, diff --git a/lib/features/onboarding/presentation/onboarding_screen.dart b/lib/features/onboarding/presentation/onboarding_screen.dart index 612e0de..9100c1e 100644 --- a/lib/features/onboarding/presentation/onboarding_screen.dart +++ b/lib/features/onboarding/presentation/onboarding_screen.dart @@ -134,7 +134,9 @@ class _OnboardingScreenState extends State { final nick = _nickname.text.trim().isEmpty ? 'AndroidIRCX' : _nickname.text.trim(); - final alt = _altNick.text.trim().isEmpty ? '${nick}_' : _altNick.text.trim(); + final alt = _altNick.text.trim().isEmpty + ? '${nick}_' + : _altNick.text.trim(); final realName = _realName.text.trim().isEmpty ? 'AndroidIRCX User' : _realName.text.trim(); @@ -300,7 +302,10 @@ class _OnboardingScreenState extends State { color: theme.colorScheme.primary, ), const SizedBox(height: 16), - Text('Stay reachable in the background', style: theme.textTheme.titleMedium), + Text( + 'Stay reachable in the background', + style: theme.textTheme.titleMedium, + ), const SizedBox(height: 8), Text( 'Allow notifications so highlights and private messages can alert you, ' @@ -379,9 +384,7 @@ class _OnboardingScreenState extends State { const SizedBox(height: 12), OutlinedButton.icon( onPressed: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const DataPrivacyScreen(), - ), + MaterialPageRoute(builder: (_) => const DataPrivacyScreen()), ), icon: const Icon(Icons.privacy_tip_outlined), label: const Text('Read data & privacy details'), diff --git a/lib/features/settings/presentation/backup_screen.dart b/lib/features/settings/presentation/backup_screen.dart index 3bef3e4..fe3dc25 100644 --- a/lib/features/settings/presentation/backup_screen.dart +++ b/lib/features/settings/presentation/backup_screen.dart @@ -27,7 +27,8 @@ class _BackupScreenState extends State { @override void initState() { super.initState(); - _service = widget.service ?? + _service = + widget.service ?? BackupService( networkRepository: SharedPrefsNetworkRepository( secretStorage: FlutterSecureSecretStorage(), @@ -79,9 +80,7 @@ class _BackupScreenState extends State { ), ); } catch (error) { - messenger.showSnackBar( - SnackBar(content: Text('Import failed: $error')), - ); + messenger.showSnackBar(SnackBar(content: Text('Import failed: $error'))); } finally { if (mounted) { setState(() => _busy = false); diff --git a/lib/features/settings/presentation/command_aliases_screen.dart b/lib/features/settings/presentation/command_aliases_screen.dart new file mode 100644 index 0000000..265eb8f --- /dev/null +++ b/lib/features/settings/presentation/command_aliases_screen.dart @@ -0,0 +1,182 @@ +import 'dart:async'; + +import 'package:androidircx/features/chat/application/command_service.dart'; +import 'package:flutter/material.dart'; + +/// Manage user-defined command aliases (e.g. /gm → /msg GameMaster). +/// Built-in shortcuts are listed read-only and can be shadowed. +class CommandAliasesScreen extends StatefulWidget { + const CommandAliasesScreen({super.key, this.commandService}); + + /// Overridable for tests; defaults to a fresh service over the shared + /// alias storage. + final CommandService? commandService; + + @override + State createState() => _CommandAliasesScreenState(); +} + +class _CommandAliasesScreenState extends State { + late final CommandService _service; + bool _loaded = false; + + @override + void initState() { + super.initState(); + _service = widget.commandService ?? CommandService(); + unawaited(_load()); + } + + Future _load() async { + await _service.load(); + if (mounted) { + setState(() => _loaded = true); + } + } + + Future _editAlias({String? alias, String? command}) async { + final result = await showDialog<({String alias, String command})>( + context: context, + builder: (context) => _AliasEditDialog(alias: alias, command: command), + ); + if (result == null || !mounted) { + return; + } + final error = await _service.setAlias(result.alias, result.command); + if (!mounted) { + return; + } + if (error != null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error))); + return; + } + setState(() {}); + } + + Future _removeAlias(String alias) async { + await _service.removeAlias(alias); + if (mounted) { + setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + final aliases = _loaded ? _service.aliases : const []; + return Scaffold( + appBar: AppBar(title: const Text('Command aliases')), + floatingActionButton: FloatingActionButton( + key: const Key('alias-add'), + onPressed: () => unawaited(_editAlias()), + tooltip: 'Add alias', + child: const Icon(Icons.add), + ), + body: SafeArea( + child: !_loaded + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + for (final alias in aliases) + ListTile( + key: Key('alias-row-${alias.alias}'), + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.bolt_outlined), + title: Text('/${alias.alias}'), + subtitle: Text( + _service.isCustomAlias(alias.alias) + ? alias.command + : '${alias.command} • built-in', + ), + onTap: () => unawaited( + _editAlias(alias: alias.alias, command: alias.command), + ), + trailing: _service.isCustomAlias(alias.alias) + ? IconButton( + key: Key('alias-remove-${alias.alias}'), + tooltip: 'Remove', + icon: const Icon(Icons.delete_outline), + onPressed: () => + unawaited(_removeAlias(alias.alias)), + ) + : null, + ), + ], + ), + ), + ); + } +} + +/// Owns its text controllers so they outlive the dialog's exit animation. +class _AliasEditDialog extends StatefulWidget { + const _AliasEditDialog({this.alias, this.command}); + + final String? alias; + final String? command; + + @override + State<_AliasEditDialog> createState() => _AliasEditDialogState(); +} + +class _AliasEditDialogState extends State<_AliasEditDialog> { + late final TextEditingController _aliasController = TextEditingController( + text: widget.alias ?? '', + ); + late final TextEditingController _commandController = TextEditingController( + text: widget.command ?? '', + ); + + @override + void dispose() { + _aliasController.dispose(); + _commandController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: Text(widget.alias == null ? 'Add alias' : 'Edit alias'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + key: const Key('alias-name-field'), + controller: _aliasController, + enabled: widget.alias == null, + decoration: const InputDecoration( + labelText: 'Alias', + helperText: 'One word, e.g. gm', + ), + ), + const SizedBox(height: 12), + TextField( + key: const Key('alias-command-field'), + controller: _commandController, + decoration: const InputDecoration( + labelText: 'Command', + helperText: 'e.g. /msg GameMaster', + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + key: const Key('alias-save'), + onPressed: () => Navigator.of(context).pop(( + alias: _aliasController.text, + command: _commandController.text, + )), + child: const Text('Save'), + ), + ], + ); + } +} diff --git a/lib/features/settings/presentation/crash_reports_screen.dart b/lib/features/settings/presentation/crash_reports_screen.dart index 4150100..f11622a 100644 --- a/lib/features/settings/presentation/crash_reports_screen.dart +++ b/lib/features/settings/presentation/crash_reports_screen.dart @@ -64,9 +64,9 @@ class _CrashReportsScreenState extends State { if (!mounted) { return; } - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Crash reports cleared.')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Crash reports cleared.'))); } @override diff --git a/lib/features/settings/presentation/kick_ban_reasons_screen.dart b/lib/features/settings/presentation/kick_ban_reasons_screen.dart new file mode 100644 index 0000000..679c629 --- /dev/null +++ b/lib/features/settings/presentation/kick_ban_reasons_screen.dart @@ -0,0 +1,150 @@ +import 'dart:async'; + +import 'package:androidircx/features/chat/data/kick_ban_reasons_repository.dart'; +import 'package:flutter/material.dart'; + +/// Manage the preset kick/ban reasons offered by the moderation dialog. +class KickBanReasonsScreen extends StatefulWidget { + const KickBanReasonsScreen({super.key, this.repository}); + + final KickBanReasonsRepository? repository; + + @override + State createState() => _KickBanReasonsScreenState(); +} + +class _KickBanReasonsScreenState extends State { + late final KickBanReasonsRepository _repository; + List _reasons = const []; + bool _loaded = false; + + @override + void initState() { + super.initState(); + _repository = widget.repository ?? KickBanReasonsRepository(); + unawaited(_load()); + } + + Future _load() async { + final reasons = await _repository.loadReasons(); + if (!mounted) { + return; + } + setState(() { + _reasons = reasons; + _loaded = true; + }); + } + + Future _save(List next) async { + setState(() => _reasons = next); + await _repository.saveReasons(next); + } + + Future _addReason() async { + final reason = await showDialog( + context: context, + builder: (context) => const _AddReasonDialog(), + ); + final trimmed = (reason ?? '').trim(); + if (trimmed.isEmpty || !mounted) { + return; + } + if (_reasons.any((item) => item.toLowerCase() == trimmed.toLowerCase())) { + return; + } + await _save([..._reasons, trimmed]); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Kick/ban reasons'), + actions: [ + IconButton( + key: const Key('kick-ban-reasons-reset'), + tooltip: 'Restore defaults', + icon: const Icon(Icons.restore), + onPressed: () => + unawaited(_save(KickBanReasonsRepository.defaultReasons)), + ), + ], + ), + floatingActionButton: FloatingActionButton( + key: const Key('kick-ban-reason-add'), + onPressed: () => unawaited(_addReason()), + tooltip: 'Add reason', + child: const Icon(Icons.add), + ), + body: SafeArea( + child: !_loaded + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + for (final reason in _reasons) + ListTile( + key: Key('kick-ban-reason-row-$reason'), + contentPadding: EdgeInsets.zero, + title: Text(reason), + trailing: IconButton( + key: Key('kick-ban-reason-remove-$reason'), + tooltip: 'Remove', + icon: const Icon(Icons.delete_outline), + onPressed: () => unawaited( + _save([ + for (final item in _reasons) + if (item != reason) item, + ]), + ), + ), + ), + ], + ), + ), + ); + } +} + +/// Owns its text controller so it outlives the dialog's exit animation. +class _AddReasonDialog extends StatefulWidget { + const _AddReasonDialog(); + + @override + State<_AddReasonDialog> createState() => _AddReasonDialogState(); +} + +class _AddReasonDialogState extends State<_AddReasonDialog> { + final TextEditingController _controller = TextEditingController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Add reason'), + content: TextField( + key: const Key('kick-ban-reason-field'), + controller: _controller, + autofocus: true, + decoration: const InputDecoration(labelText: 'Reason'), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + FilledButton( + key: const Key('kick-ban-reason-save'), + onPressed: () => Navigator.of(context).pop(_controller.text), + child: const Text('Add'), + ), + ], + ); + } +} diff --git a/lib/features/settings/presentation/message_format_screen.dart b/lib/features/settings/presentation/message_format_screen.dart new file mode 100644 index 0000000..670e708 --- /dev/null +++ b/lib/features/settings/presentation/message_format_screen.dart @@ -0,0 +1,204 @@ +import 'package:androidircx/core/models/app_settings.dart'; +import 'package:androidircx/features/chat/presentation/message_line_format.dart'; +import 'package:flutter/material.dart'; + +/// Visual editor for how message lines are laid out: timestamp format and +/// position, plus sender nick decoration. Changes save immediately through +/// [onSettingsChanged] and the preview updates live. +class MessageFormatScreen extends StatefulWidget { + const MessageFormatScreen({ + super.key, + required this.initialSettings, + required this.onSettingsChanged, + }); + + final AppSettings initialSettings; + final ValueChanged onSettingsChanged; + + @override + State createState() => _MessageFormatScreenState(); +} + +class _MessageFormatScreenState extends State { + late AppSettings _settings; + + @override + void initState() { + super.initState(); + _settings = widget.initialSettings; + } + + void _update(AppSettings next) { + setState(() => _settings = next); + widget.onSettingsChanged(next); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar(title: const Text('Message format')), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text('Preview', style: theme.textTheme.titleSmall), + const SizedBox(height: 8), + _MessageFormatPreview(settings: _settings), + const SizedBox(height: 16), + SwitchListTile( + key: const Key('message-format-show-timestamps'), + contentPadding: EdgeInsets.zero, + title: const Text('Show timestamps'), + value: _settings.showTimestamps, + onChanged: (value) => + _update(_settings.copyWith(showTimestamps: value)), + ), + if (_settings.showTimestamps) ...[ + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Timestamp format'), + trailing: DropdownButton( + key: const Key('message-format-timestamp-format'), + value: + supportedTimestampFormats.any( + (option) => option.pattern == _settings.timestampFormat, + ) + ? _settings.timestampFormat + : 'HH:mm', + onChanged: (value) { + if (value == null) { + return; + } + _update(_settings.copyWith(timestampFormat: value)); + }, + items: supportedTimestampFormats + .map( + (option) => DropdownMenuItem( + value: option.pattern, + child: Text(option.label), + ), + ) + .toList(growable: false), + ), + ), + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Timestamp position'), + trailing: DropdownButton( + key: const Key('message-format-timestamp-position'), + value: _settings.timestampPosition, + onChanged: (value) { + if (value == null) { + return; + } + _update(_settings.copyWith(timestampPosition: value)); + }, + items: TimestampPosition.values + .map( + (position) => DropdownMenuItem( + value: position, + child: Text(timestampPositionLabel(position)), + ), + ) + .toList(growable: false), + ), + ), + ], + ListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Nick style'), + subtitle: const Text('Decoration around sender names.'), + trailing: DropdownButton( + key: const Key('message-format-nick-style'), + value: _settings.nickDisplayFormat, + onChanged: (value) { + if (value == null) { + return; + } + _update(_settings.copyWith(nickDisplayFormat: value)); + }, + items: NickDisplayFormat.values + .map( + (format) => DropdownMenuItem( + value: format, + child: Text(nickDisplayFormatLabel(format)), + ), + ) + .toList(growable: false), + ), + ), + ], + ), + ), + ); + } +} + +class _MessageFormatPreview extends StatelessWidget { + const _MessageFormatPreview({required this.settings}); + + final AppSettings settings; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final sampleTime = DateTime(2026, 1, 1, 13, 5, 9); + final nickStyle = theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.primary, + ); + final metaStyle = theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ); + + List lineSpans(String nick, String message) { + final timeSpan = settings.showTimestamps + ? TextSpan( + text: formatIrcTimestamp(sampleTime, settings.timestampFormat), + style: metaStyle, + ) + : null; + final nickSpan = TextSpan( + text: formatNickLabel(nick, settings.nickDisplayFormat), + style: nickStyle, + ); + return [ + if (timeSpan != null && + settings.timestampPosition == TimestampPosition.beforeNick) ...[ + timeSpan, + const TextSpan(text: ' '), + ], + nickSpan, + if (timeSpan != null && + settings.timestampPosition == TimestampPosition.afterNick) ...[ + const TextSpan(text: ' '), + timeSpan, + ], + const TextSpan(text: ' '), + TextSpan(text: message, style: theme.textTheme.bodyMedium), + ]; + } + + return Container( + key: const Key('message-format-preview'), + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text.rich(TextSpan(children: lineSpans('alice', 'Hello there!'))), + const SizedBox(height: 6), + Text.rich( + TextSpan(children: lineSpans('bob', 'hi alice, welcome back')), + ), + ], + ), + ); + } +} diff --git a/lib/features/settings/presentation/settings_screen.dart b/lib/features/settings/presentation/settings_screen.dart index 3149f35..5399e33 100644 --- a/lib/features/settings/presentation/settings_screen.dart +++ b/lib/features/settings/presentation/settings_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:androidircx/app/theme/app_theme.dart'; +import 'package:androidircx/core/app/app_version.dart'; import 'package:androidircx/core/models/app_settings.dart'; import 'package:androidircx/core/platform/app_permissions.dart'; import 'package:androidircx/core/presets/server_preset_service.dart'; @@ -13,15 +14,32 @@ import 'package:androidircx/features/connections/presentation/server_directory_p import 'package:androidircx/features/monetization/presentation/purchase_screen.dart'; import 'package:androidircx/features/onboarding/presentation/data_privacy_screen.dart'; import 'package:androidircx/features/settings/presentation/backup_screen.dart'; +import 'package:androidircx/features/settings/presentation/command_aliases_screen.dart'; +import 'package:androidircx/features/settings/presentation/kick_ban_reasons_screen.dart'; import 'package:androidircx/features/settings/presentation/crash_reports_screen.dart'; +import 'package:androidircx/features/settings/presentation/message_format_screen.dart'; +import 'package:androidircx/features/settings/presentation/sound_settings_screen.dart'; 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/foundation.dart' show defaultTargetPlatform; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:local_auth/local_auth.dart'; +/// Message font choices. Values are Android built-in family names ('system' +/// maps to the platform default); no font assets are bundled. +const _messageFontOptions = <({String family, String label})>[ + (family: 'system', label: 'System default'), + (family: 'sans-serif', label: 'Sans-serif'), + (family: 'sans-serif-light', label: 'Sans-serif Light'), + (family: 'sans-serif-medium', label: 'Sans-serif Medium'), + (family: 'sans-serif-condensed', label: 'Sans-serif Condensed'), + (family: 'serif', label: 'Serif'), + (family: 'monospace', label: 'Monospace'), +]; + class SettingsScreen extends StatefulWidget { const SettingsScreen({ super.key, @@ -66,6 +84,7 @@ class _SettingsScreenState extends State { bool? _lastHasNoAds; bool _isLoading = true; bool _didResolveController = false; + bool _hasBatteryExemption = false; AppPermissions get _permissions => widget.permissions ?? const PermissionHandlerAppPermissions(); @@ -288,18 +307,44 @@ class _SettingsScreenState extends State { ), ), const Divider(height: 1), - SwitchListTile( - key: const Key('settings-monospace-messages'), - title: const Text('Monospace messages'), + ListTile( + title: const Text('Message font'), subtitle: const Text( - 'Render IRC message bodies in a fixed-width font.', + 'Typeface used for IRC message bodies.', + ), + trailing: DropdownButton( + key: const Key('settings-message-font-family'), + value: _effectiveMessageFontFamily, + onChanged: (value) async { + if (value == null) { + return; + } + await _saveSettings( + _settings.copyWith( + messageFontFamily: value, + monospaceMessages: value == 'monospace', + ), + ); + }, + items: _messageFontOptions + .map( + (option) => DropdownMenuItem( + value: option.family, + child: Text(option.label), + ), + ) + .toList(growable: false), ), - value: _settings.monospaceMessages, - onChanged: (value) async { - await _saveSettings( - _settings.copyWith(monospaceMessages: value), - ); - }, + ), + const Divider(height: 1), + ListTile( + key: const Key('settings-message-format'), + leading: const Icon(Icons.short_text), + title: const Text('Message format'), + subtitle: const Text( + 'Timestamp format/position and nick style.', + ), + onTap: _openMessageFormatEditor, ), const Divider(height: 1), ListTile( @@ -484,6 +529,31 @@ class _SettingsScreenState extends State { _SettingsSection( title: 'Notifications', children: [ + ListTile( + key: const Key('settings-sound-settings'), + leading: const Icon(Icons.music_note_outlined), + title: const Text('Sounds'), + subtitle: const Text( + 'Per-event sounds, volume, and preview.', + ), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const SoundSettingsScreen(), + ), + ), + ), + const Divider(height: 1), + ListTile( + key: const Key('settings-system-channels'), + leading: const Icon(Icons.vibration), + title: const Text('Vibration & LED'), + subtitle: const Text( + 'Configured per notification channel in Android ' + 'system settings.', + ), + onTap: () => unawaited(_permissions.openSettingsPage()), + ), + const Divider(height: 1), SwitchListTile( key: const Key('settings-notifications-enabled'), secondary: const Icon( @@ -574,6 +644,24 @@ class _SettingsScreenState extends State { _settings.copyWith(analyticsConsent: value), ), ), + const Divider(height: 1), + ListTile( + key: const Key('settings-battery-optimization'), + leading: const Icon(Icons.battery_saver_outlined), + title: const Text('Battery optimization'), + subtitle: Text( + _hasBatteryExemption + ? 'Exempted — background connections stay alive.' + : 'Ask Android to keep IRC connections alive in ' + 'the background.', + ), + trailing: _hasBatteryExemption + ? const Icon(Icons.check_circle_outline) + : null, + onTap: _hasBatteryExemption + ? null + : () => unawaited(_requestBatteryExemption()), + ), ], ), const SizedBox(height: 12), @@ -626,6 +714,55 @@ class _SettingsScreenState extends State { _settings.copyWith(showSendButton: value), ), ), + const Divider(height: 1), + SwitchListTile( + key: const Key('settings-composer-autocorrect'), + title: const Text('Autocorrect'), + subtitle: const Text( + 'Let the keyboard auto-correct while typing.', + ), + value: _settings.composerAutocorrect, + onChanged: (value) => _saveSettings( + _settings.copyWith(composerAutocorrect: value), + ), + ), + const Divider(height: 1), + SwitchListTile( + key: const Key('settings-composer-suggestions'), + title: const Text('Keyboard suggestions'), + subtitle: const Text( + 'Show the keyboard suggestion strip.', + ), + value: _settings.composerSuggestions, + onChanged: (value) => _saveSettings( + _settings.copyWith(composerSuggestions: value), + ), + ), + const Divider(height: 1), + SwitchListTile( + key: const Key('settings-composer-capitalize'), + title: const Text('Capitalize sentences'), + value: _settings.composerCapitalizeSentences, + onChanged: (value) => _saveSettings( + _settings.copyWith( + composerCapitalizeSentences: value, + ), + ), + ), + const Divider(height: 1), + ListTile( + key: const Key('settings-command-aliases'), + leading: const Icon(Icons.bolt_outlined), + title: const Text('Command aliases'), + subtitle: const Text( + 'Shortcuts like /j for /join; add your own.', + ), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const CommandAliasesScreen(), + ), + ), + ), ], ), const SizedBox(height: 12), @@ -769,6 +906,43 @@ class _SettingsScreenState extends State { _settings.copyWith(autoRejoinOnKick: value), ), ), + const Divider(height: 1), + ListTile( + key: const Key('settings-kick-ban-reasons'), + leading: const Icon(Icons.gavel_outlined), + title: const Text('Kick/ban reasons'), + subtitle: const Text( + 'Preset reasons offered by the moderation dialog.', + ), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const KickBanReasonsScreen(), + ), + ), + ), + ], + ), + const SizedBox(height: 12), + _SettingsSection( + title: 'Advanced', + children: [ + const ListTile( + key: Key('settings-app-version'), + leading: Icon(Icons.info_outline), + title: Text('App version'), + subtitle: Text('AndroidIRCX Flutter v$appVersion'), + ), + const Divider(height: 1), + ListTile( + key: const Key('settings-copy-diagnostics'), + leading: const Icon(Icons.bug_report_outlined), + title: const Text('Copy diagnostics'), + subtitle: const Text( + 'Copy version and display settings for bug reports. ' + 'Contains no passwords or chat content.', + ), + onTap: () => unawaited(_copyDiagnostics()), + ), ], ), const SizedBox(height: 12), @@ -910,6 +1084,17 @@ class _SettingsScreenState extends State { controller.selection = TextSelection.collapsed(offset: value.length); } + Future _openMessageFormatEditor() async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => MessageFormatScreen( + initialSettings: _settings, + onSettingsChanged: _saveSettings, + ), + ), + ); + } + Future _openThemeEditor() async { await Navigator.of(context).push( MaterialPageRoute( @@ -977,14 +1162,56 @@ class _SettingsScreenState extends State { /// on while the OS permission is granted. Future _refreshPermissionStatuses() async { final hasNotifications = await _permissions.hasNotifications(); + final hasBatteryExemption = await _permissions + .hasIgnoreBatteryOptimizations(); if (!mounted) { return; } + setState(() => _hasBatteryExemption = hasBatteryExemption); if (_settings.notificationsEnabled && !hasNotifications) { await _saveSettings(_settings.copyWith(notificationsEnabled: false)); } } + Future _copyDiagnostics() async { + final diagnostics = [ + 'AndroidIRCX Flutter v$appVersion', + 'Platform: $defaultTargetPlatform', + 'Theme: ${_settings.themePreset.name}', + 'Density: ${_settings.messageDensity.name}', + 'Font: ${_settings.messageFontFamily} ' + '(${(_settings.messageFontScale * 100).round()}%)', + 'Timestamps: ${_settings.showTimestamps} ' + '(${_settings.timestampFormat}, ' + '${_settings.timestampPosition.name})', + 'Nick style: ${_settings.nickDisplayFormat.name}', + 'Notifications: ${_settings.notificationsEnabled}', + 'Battery exemption: $_hasBatteryExemption', + 'History retention/tab: ${_settings.historyRetentionPerTab}', + ].join('\n'); + await Clipboard.setData(ClipboardData(text: diagnostics)); + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Diagnostics copied to clipboard.')), + ); + } + + Future _requestBatteryExemption() async { + final result = await _permissions.requestIgnoreBatteryOptimizations(); + if (!mounted) { + return; + } + if (result == AppPermissionResult.granted) { + setState(() => _hasBatteryExemption = true); + return; + } + if (result == AppPermissionResult.permanentlyDenied) { + await _permissions.openSettingsPage(); + } + } + Future _toggleNotifications(bool value) async { if (!value) { await _saveSettings(_settings.copyWith(notificationsEnabled: false)); @@ -1242,6 +1469,18 @@ class _SettingsScreenState extends State { }; } + /// Current font-family dropdown value, folding the legacy monospace toggle + /// into 'monospace' so old settings show the right selection. + String get _effectiveMessageFontFamily { + final family = _settings.messageFontFamily; + if (family == 'system' && _settings.monospaceMessages) { + return 'monospace'; + } + return _messageFontOptions.any((option) => option.family == family) + ? family + : 'system'; + } + String _labelForNickColorMode(NickColorMode mode) { return switch (mode) { NickColorMode.none => 'Off', diff --git a/lib/features/settings/presentation/sound_settings_screen.dart b/lib/features/settings/presentation/sound_settings_screen.dart new file mode 100644 index 0000000..2b1409e --- /dev/null +++ b/lib/features/settings/presentation/sound_settings_screen.dart @@ -0,0 +1,106 @@ +import 'package:androidircx/core/sound/sound_service.dart'; +import 'package:flutter/material.dart'; + +/// Per-event notification sound settings with preview playback. +class SoundSettingsScreen extends StatefulWidget { + const SoundSettingsScreen({super.key, this.service}); + + /// Overridable for tests; defaults to the app-wide [SoundScope] service. + final SoundService? service; + + @override + State createState() => _SoundSettingsScreenState(); +} + +class _SoundSettingsScreenState extends State { + SoundService? _service; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final resolved = widget.service ?? SoundScope.maybeOf(context); + if (identical(resolved, _service)) { + return; + } + _service?.removeListener(_onServiceChanged); + _service = resolved; + _service?.addListener(_onServiceChanged); + } + + @override + void dispose() { + _service?.removeListener(_onServiceChanged); + super.dispose(); + } + + void _onServiceChanged() { + if (mounted) { + setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + final service = _service; + if (service == null) { + return Scaffold( + appBar: AppBar(title: const Text('Sounds')), + body: const Center(child: Text('Sound service unavailable.')), + ); + } + final settings = service.settings; + return Scaffold( + appBar: AppBar(title: const Text('Sounds')), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + SwitchListTile( + key: const Key('sound-settings-enabled'), + contentPadding: EdgeInsets.zero, + title: const Text('Event sounds'), + subtitle: const Text('Play short sounds for IRC events.'), + value: settings.enabled, + onChanged: (value) => + service.updateSettings(settings.copyWith(enabled: value)), + ), + if (settings.enabled) ...[ + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + 'Volume', + style: Theme.of(context).textTheme.titleSmall, + ), + ), + Slider( + key: const Key('sound-settings-volume'), + value: settings.masterVolume, + divisions: 10, + label: '${(settings.masterVolume * 100).round()}%', + onChanged: (value) => service.updateSettings( + settings.copyWith(masterVolume: value), + ), + ), + const Divider(), + for (final event in SoundEvent.values) + SwitchListTile( + key: Key('sound-settings-event-${event.name}'), + contentPadding: EdgeInsets.zero, + title: Text(soundEventLabels[event] ?? event.name), + value: settings.isEventEnabled(event), + onChanged: (value) => + service.updateSettings(settings.withEvent(event, value)), + secondary: IconButton( + key: Key('sound-settings-preview-${event.name}'), + tooltip: 'Play sound', + icon: const Icon(Icons.play_circle_outline), + onPressed: () => service.previewEvent(event), + ), + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/features/settings/presentation/theme_editor_screen.dart b/lib/features/settings/presentation/theme_editor_screen.dart index 05d6177..6cb1acb 100644 --- a/lib/features/settings/presentation/theme_editor_screen.dart +++ b/lib/features/settings/presentation/theme_editor_screen.dart @@ -88,10 +88,7 @@ class _ThemeEditorScreenState extends State { Future _editColor(String key, String label) async { final picked = await showDialog( context: context, - builder: (_) => _ColorPickerDialog( - label: label, - initial: _colorFor(key), - ), + builder: (_) => _ColorPickerDialog(label: label, initial: _colorFor(key)), ); if (picked != null) { setState(() => _theme[key] = _colorToHex(picked)); diff --git a/lib/irc/encoding/irc_encoding.dart b/lib/irc/encoding/irc_encoding.dart new file mode 100644 index 0000000..87515b7 --- /dev/null +++ b/lib/irc/encoding/irc_encoding.dart @@ -0,0 +1,181 @@ +import 'dart:convert'; + +import 'package:enough_convert/enough_convert.dart'; + +/// Character-encoding support for IRC traffic. +/// +/// IRC is a byte protocol with no in-band charset negotiation, so different +/// networks/users send text in different legacy encodings (ISO-8859-*, +/// Windows-125x, KOI8, ...). Incoming line bytes are decoded and outgoing +/// lines encoded using a per-network encoding, with an optional "prefer +/// UTF-8, fall back to legacy" mode for mixed channels. +/// +/// Decoding is done per complete IRC line (bytes are split on LF before +/// decoding), so no streaming state is needed and multi-byte characters never +/// straddle a chunk boundary — all supported encodings keep 0x0A/0x0D as +/// ASCII control bytes. +const String defaultIrcEncoding = 'utf-8'; + +class IrcEncodingOption { + const IrcEncodingOption(this.label, this.name); + + /// Canonical lower-case label (also what we persist). + final String label; + + /// Human-readable name shown in settings. + final String name; +} + +/// Curated list of encodings offered in the UI, ordered by how common they +/// are on IRC. `utf-8` is the modern default; the rest are legacy charsets +/// still used on older networks and by older clients. +const List supportedIrcEncodings = [ + IrcEncodingOption('utf-8', 'UTF-8 (Unicode)'), + IrcEncodingOption('iso-8859-1', 'Western (ISO-8859-1)'), + IrcEncodingOption('iso-8859-15', 'Western (ISO-8859-15)'), + IrcEncodingOption('windows-1252', 'Western (Windows-1252)'), + IrcEncodingOption('iso-8859-2', 'Central European (ISO-8859-2)'), + IrcEncodingOption('windows-1250', 'Central European (Windows-1250)'), + IrcEncodingOption('windows-1251', 'Cyrillic (Windows-1251)'), + IrcEncodingOption('koi8-r', 'Cyrillic (KOI8-R)'), + IrcEncodingOption('koi8-u', 'Cyrillic (KOI8-U)'), + IrcEncodingOption('iso-8859-5', 'Cyrillic (ISO-8859-5)'), + IrcEncodingOption('iso-8859-7', 'Greek (ISO-8859-7)'), + IrcEncodingOption('windows-1253', 'Greek (Windows-1253)'), + IrcEncodingOption('iso-8859-9', 'Turkish (ISO-8859-9)'), + IrcEncodingOption('windows-1254', 'Turkish (Windows-1254)'), + IrcEncodingOption('iso-8859-13', 'Baltic (ISO-8859-13)'), + IrcEncodingOption('windows-1256', 'Arabic (Windows-1256)'), + IrcEncodingOption('gbk', 'Chinese Simplified (GBK)'), + IrcEncodingOption('big5', 'Chinese Traditional (Big5)'), +]; + +final Set _supportedLabels = supportedIrcEncodings + .map((option) => option.label) + .toSet(); + +/// Normalizes a persisted label to a canonical, lower-case supported form. +String normalizeIrcEncoding(String? label) { + final value = (label ?? defaultIrcEncoding).toLowerCase().trim(); + return _supportedLabels.contains(value) ? value : defaultIrcEncoding; +} + +/// Display name for a label (falls back to the normalized label). +String ircEncodingDisplayName(String? label) { + final normalized = normalizeIrcEncoding(label); + for (final option in supportedIrcEncodings) { + if (option.label == normalized) { + return option.name; + } + } + return normalized; +} + +Encoding? _legacyCodec(String normalizedLabel) { + return switch (normalizedLabel) { + 'iso-8859-1' => const Latin1Codec(allowInvalid: true), + 'iso-8859-2' => const Latin2Codec(allowInvalid: true), + 'iso-8859-5' => const Latin5Codec(allowInvalid: true), + 'iso-8859-7' => const Latin7Codec(allowInvalid: true), + 'iso-8859-9' => const Latin9Codec(allowInvalid: true), + 'iso-8859-13' => const Latin13Codec(allowInvalid: true), + 'iso-8859-15' => const Latin15Codec(allowInvalid: true), + 'windows-1250' => const Windows1250Codec(allowInvalid: true), + 'windows-1251' => const Windows1251Codec(allowInvalid: true), + 'windows-1252' => const Windows1252Codec(allowInvalid: true), + 'windows-1253' => const Windows1253Codec(allowInvalid: true), + 'windows-1254' => const Windows1254Codec(allowInvalid: true), + 'windows-1256' => const Windows1256Codec(allowInvalid: true), + 'koi8-r' => const Koi8rCodec(allowInvalid: true), + 'koi8-u' => const Koi8uCodec(allowInvalid: true), + 'gbk' => const GbkCodec(allowInvalid: true), + 'big5' => const Big5Codec(allowInvalid: true), + _ => null, + }; +} + +/// Decodes the bytes of a single IRC line (without the trailing CRLF). +/// +/// When [utf8Fallback] is true and [encoding] is a legacy charset, the line is +/// decoded as UTF-8 first and only falls back to the legacy charset if the +/// bytes are not valid UTF-8. Ignored for `utf-8`. +String decodeIrcLine( + List bytes, { + String encoding = defaultIrcEncoding, + bool utf8Fallback = false, +}) { + final normalized = normalizeIrcEncoding(encoding); + if (normalized == defaultIrcEncoding) { + return utf8.decode(bytes, allowMalformed: true); + } + if (utf8Fallback) { + try { + return utf8.decode(bytes); + } on FormatException { + // Not valid UTF-8 — decode this line with the legacy charset instead. + } + } + final codec = _legacyCodec(normalized); + if (codec == null) { + return utf8.decode(bytes, allowMalformed: true); + } + return codec.decode(bytes); +} + +/// Encodes an outgoing IRC line for the wire. +/// +/// Returns legacy bytes, or `null` when the caller should use the plain +/// UTF-8 string write path (encoding is `utf-8`, or [utf8Fallback] prefers +/// sending UTF-8). Returning null keeps the common UTF-8 path byte-identical +/// to the previous behavior. +List? encodeIrcLine( + String line, { + String encoding = defaultIrcEncoding, + bool utf8Fallback = false, +}) { + final normalized = normalizeIrcEncoding(encoding); + if (normalized == defaultIrcEncoding || utf8Fallback) { + return null; + } + final codec = _legacyCodec(normalized); + if (codec == null) { + return null; + } + try { + return codec.encode(line); + } on FormatException { + // Unencodable line — fall back to UTF-8 rather than dropping the send. + return null; + } +} + +/// Splits raw socket bytes into per-line byte lists on LF, stripping a +/// trailing CR. All supported encodings keep 0x0A and 0x0D as ASCII control +/// bytes, so line splitting is safe before decoding and decoding a whole line +/// keeps multi-byte characters intact. +class IrcByteLineSplitter { + final List _buffer = []; + + /// Adds [chunk] and returns every complete line's bytes (without CRLF). + List> addChunk(List chunk) { + _buffer.addAll(chunk); + final lines = >[]; + var start = 0; + while (true) { + final newlineIndex = _buffer.indexOf(0x0a, start); + if (newlineIndex == -1) { + break; + } + var end = newlineIndex; + if (end > start && _buffer[end - 1] == 0x0d) { + end -= 1; + } + lines.add(_buffer.sublist(start, end)); + start = newlineIndex + 1; + } + if (start > 0) { + _buffer.removeRange(0, start); + } + return lines; + } +} diff --git a/lib/irc/parser/ctcp.dart b/lib/irc/parser/ctcp.dart index 6e9b2a4..7f2f60c 100644 --- a/lib/irc/parser/ctcp.dart +++ b/lib/irc/parser/ctcp.dart @@ -1,9 +1,5 @@ class CtcpMessage { - const CtcpMessage({ - required this.isCtcp, - this.command, - this.args, - }); + const CtcpMessage({required this.isCtcp, this.command, this.args}); final bool isCtcp; final String? command; @@ -13,7 +9,8 @@ class CtcpMessage { const _ctcpDelimiter = '\u0001'; CtcpMessage parseCtcp(String message) { - if (!message.startsWith(_ctcpDelimiter) || !message.endsWith(_ctcpDelimiter)) { + if (!message.startsWith(_ctcpDelimiter) || + !message.endsWith(_ctcpDelimiter)) { return const CtcpMessage(isCtcp: false); } diff --git a/lib/irc/parser/irc_url_parser.dart b/lib/irc/parser/irc_url_parser.dart index 9a757cd..99df205 100644 --- a/lib/irc/parser/irc_url_parser.dart +++ b/lib/irc/parser/irc_url_parser.dart @@ -39,19 +39,22 @@ bool isIrcUrl(String url) { ParsedIrcUrl parseIrcUrl(String url) { ParsedIrcUrl invalid(String error) => ParsedIrcUrl( - protocol: 'irc', - server: '', - port: 6667, - ssl: false, - isValid: false, - error: error, - ); + protocol: 'irc', + server: '', + port: 6667, + ssl: false, + isValid: false, + error: error, + ); if (url.trim().isEmpty) { return invalid('URL is empty or invalid'); } - final match = RegExp(r'^(irc|ircs):\/\/', caseSensitive: false).firstMatch(url.trim()); + final match = RegExp( + r'^(irc|ircs):\/\/', + caseSensitive: false, + ).firstMatch(url.trim()); if (match == null) { return invalid('Invalid IRC URL format. Expected: irc:// or ircs://'); } diff --git a/lib/irc/parser/mirc_preset_parser.dart b/lib/irc/parser/mirc_preset_parser.dart index 8cdc50e..04c26fb 100644 --- a/lib/irc/parser/mirc_preset_parser.dart +++ b/lib/irc/parser/mirc_preset_parser.dart @@ -1,11 +1,7 @@ import 'dart:convert'; class MircPresetEntry { - const MircPresetEntry({ - required this.id, - required this.raw, - this.enabled, - }); + const MircPresetEntry({required this.id, required this.raw, this.enabled}); final String id; final String raw; @@ -83,7 +79,10 @@ List parseNickCompletionPresets(String raw) { final lines = splitPresetLines(raw); return List.generate(lines.length, (index) { final line = lines[index]; - final match = RegExp(r'(\s+|\x08)(on|off)$', caseSensitive: false).firstMatch(line); + final match = RegExp( + r'(\s+|\x08)(on|off)$', + caseSensitive: false, + ).firstMatch(line); if (match == null) { return MircPresetEntry(id: 'nick-${index + 1}', raw: line); } diff --git a/lib/irc/services/irc_transport_connector_io.dart b/lib/irc/services/irc_transport_connector_io.dart index 2c8244a..93a20bc 100644 --- a/lib/irc/services/irc_transport_connector_io.dart +++ b/lib/irc/services/irc_transport_connector_io.dart @@ -5,6 +5,7 @@ import 'dart:typed_data'; import 'package:androidircx/core/models/network_config.dart'; import 'package:androidircx/core/security/certificate_store.dart'; +import 'package:androidircx/irc/encoding/irc_encoding.dart'; import 'package:androidircx/irc/services/irc_transport.dart'; Future connectDefaultTransport( @@ -50,11 +51,21 @@ class SocketIrcTransport implements IrcTransport { this._socket, { StreamSubscription? subscription, List initialData = const [], - }) { + String encoding = defaultIrcEncoding, + bool encodingUtf8Fallback = false, + }) : _encoding = normalizeIrcEncoding(encoding), + _encodingUtf8Fallback = encodingUtf8Fallback { _byteController = StreamController>(); + final lineSplitter = IrcByteLineSplitter(); lines = _byteController.stream - .transform(utf8.decoder) - .transform(const LineSplitter()) + .expand(lineSplitter.addChunk) + .map( + (lineBytes) => decodeIrcLine( + lineBytes, + encoding: _encoding, + utf8Fallback: _encodingUtf8Fallback, + ), + ) .where((line) => line.isNotEmpty) .asBroadcastStream(); @@ -75,6 +86,8 @@ class SocketIrcTransport implements IrcTransport { } final Socket _socket; + final String _encoding; + final bool _encodingUtf8Fallback; late final StreamController> _byteController; late final StreamSubscription _subscription; @@ -92,7 +105,11 @@ class SocketIrcTransport implements IrcTransport { network, securityContext: securityContext, ); - return SocketIrcTransport._(socket); + return SocketIrcTransport._( + socket, + encoding: network.encoding, + encodingUtf8Fallback: network.encodingUtf8Fallback, + ); } @override @@ -106,7 +123,16 @@ class SocketIrcTransport implements IrcTransport { @override Future sendLine(String line) async { - _socket.write('$line\r\n'); + final legacyBytes = encodeIrcLine( + line, + encoding: _encoding, + utf8Fallback: _encodingUtf8Fallback, + ); + if (legacyBytes != null) { + _socket.add([...legacyBytes, 0x0d, 0x0a]); + } else { + _socket.write('$line\r\n'); + } await _socket.flush(); } @@ -146,7 +172,11 @@ class SocketIrcTransport implements IrcTransport { socket, host: network.host, ); - return SocketIrcTransport._(secureSocket); + return SocketIrcTransport._( + secureSocket, + encoding: network.encoding, + encodingUtf8Fallback: network.encodingUtf8Fallback, + ); } final subscription = reader.takeSubscription(); @@ -154,6 +184,8 @@ class SocketIrcTransport implements IrcTransport { socket, subscription: subscription, initialData: remainingData, + encoding: network.encoding, + encodingUtf8Fallback: network.encodingUtf8Fallback, ); } catch (_) { await reader.cancel(); diff --git a/lib/irc/services/irc_transport_connector_web.dart b/lib/irc/services/irc_transport_connector_web.dart index fc2c6aa..ff569de 100644 --- a/lib/irc/services/irc_transport_connector_web.dart +++ b/lib/irc/services/irc_transport_connector_web.dart @@ -14,11 +14,8 @@ Future connectDefaultTransport( } class WebIrcTransport implements IrcTransport { - WebIrcTransport._( - this._incoming, - this._send, - this._onClose, - ) : _lineController = StreamController.broadcast() { + WebIrcTransport._(this._incoming, this._send, this._onClose) + : _lineController = StreamController.broadcast() { _messageSubscription = _incoming.listen( (data) { for (final line in framesFromMessage(data)) { diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index 3ccd551..2cffa44 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,11 +6,15 @@ #include "generated_plugin_registrant.h" +#include #include #include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); + audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index fbedf4a..a9cc2e1 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_linux file_selector_linux flutter_secure_storage_linux url_launcher_linux diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index a6d1c4d..0c18a5f 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,7 @@ import FlutterMacOS import Foundation +import audioplayers_darwin import file_selector_macos import firebase_analytics import firebase_app_check @@ -20,6 +21,7 @@ import video_player_avfoundation import webview_flutter_wkwebview func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FirebaseAnalyticsPlugin")) FirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FirebaseAppCheckPlugin")) diff --git a/pubspec.lock b/pubspec.lock index d6966a1..1b153ff 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: "2ba4bb2944baacbdd5372ff8254a8e7feb8c10d7739545e392f5605a8f618745" + url: "https://pub.dev" + source: hosted + version: "6.8.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: f5ff5b15620fbab8cb0849e9636c48e2b96c3f0f71723bbbe2ad3c761b205f05 + url: "https://pub.dev" + source: hosted + version: "5.3.0" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: "1ca553add991384ecf421b9569da850f3ab2472ffb83f6970b0416365abc51be" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: "15178b726b7cdee5364d0463c8d445630c4e0fb7d26612b73c767e7d25de9417" + url: "https://pub.dev" + source: hosted + version: "4.3.0" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "765f6f0e6dca55cb471c9483fc77700564b3484d19198aca4ebb5147c6c85acb" + url: "https://pub.dev" + source: hosted + version: "7.2.0" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: ae1e0103c865a03e273f6d13d97b93f5595eac09915729cd5e37ef96e2857319 + url: "https://pub.dev" + source: hosted + version: "5.3.0" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: a70ae82bba2dfcb6eb03dd4815d737a2d46d33ea5a96a03f535cfcaac490e413 + url: "https://pub.dev" + source: hosted + version: "4.4.1" boolean_selector: dependency: transitive description: @@ -225,6 +281,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.34.5" + enough_convert: + dependency: "direct main" + description: + name: enough_convert + sha256: c67d85ca21aaa0648f155907362430701db41f7ec8e6501a58ad9cd9d8569d01 + url: "https://pub.dev" + source: hosted + version: "1.6.0" fake_async: dependency: transitive description: @@ -1133,6 +1197,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "3a7b5d17422dd0f8d5c6c14feaa5a1c65638b9455f871a96f08437562c046931" + url: "https://pub.dev" + source: hosted + version: "3.4.1+2" term_glyph: dependency: transitive description: @@ -1221,6 +1293,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.5" + uuid: + dependency: transitive + description: + name: uuid + sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" + url: "https://pub.dev" + source: hosted + version: "4.6.0" vector_math: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 78f7f4d..8cca9d4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.0.10+14 +version: 1.0.11+15 environment: sdk: ^3.11.1 @@ -56,6 +56,8 @@ dependencies: firebase_app_check: ^0.4.6 google_mobile_ads: ^9.1.0 in_app_purchase: ^3.3.0 + enough_convert: ^1.6.0 + audioplayers: ^6.8.1 dev_dependencies: flutter_test: @@ -81,10 +83,8 @@ flutter: # the material Icons class. uses-material-design: true - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + assets: + - assets/sounds/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images diff --git a/test/attachment_tap_action_test.dart b/test/attachment_tap_action_test.dart index b1e117c..4a3cd79 100644 --- a/test/attachment_tap_action_test.dart +++ b/test/attachment_tap_action_test.dart @@ -2,10 +2,8 @@ import 'package:androidircx/core/models/irc_message.dart'; import 'package:androidircx/features/chat/presentation/chat_screen.dart'; import 'package:flutter_test/flutter_test.dart'; -IrcMessageAttachment att( - IrcMessageAttachmentType type, { - String? uri, -}) => IrcMessageAttachment(type: type, label: '', uri: uri); +IrcMessageAttachment att(IrcMessageAttachmentType type, {String? uri}) => + IrcMessageAttachment(type: type, label: '', uri: uri); void main() { test('null uri means no action', () { diff --git a/test/auto_mode_test.dart b/test/auto_mode_test.dart index 1971bb2..299c6f7 100644 --- a/test/auto_mode_test.dart +++ b/test/auto_mode_test.dart @@ -84,10 +84,7 @@ void main() { transport.emit(':bob!id@host JOIN #flutter'); await Future.delayed(Duration.zero); - expect( - transport.sentLines.where((l) => l.startsWith('MODE')), - isEmpty, - ); + expect(transport.sentLines.where((l) => l.startsWith('MODE')), isEmpty); controller.dispose(); }); @@ -107,9 +104,7 @@ void main() { await controller.handleComposerSubmit('/unautovoice bob'); expect( - controller.autoModeEntries.where( - (e) => e.type == UserListType.autoVoice, - ), + controller.autoModeEntries.where((e) => e.type == UserListType.autoVoice), isEmpty, ); expect(controller.autoModeEntries, hasLength(1)); diff --git a/test/backup_service_test.dart b/test/backup_service_test.dart index b51972a..328478b 100644 --- a/test/backup_service_test.dart +++ b/test/backup_service_test.dart @@ -33,9 +33,7 @@ void main() { saslPassword: 'saslsecret', ), ]); - final settings = _FakeSettings( - const AppSettings(monospaceMessages: true), - ); + final settings = _FakeSettings(const AppSettings(monospaceMessages: true)); final profiles = InMemoryIdentityProfileRepository([ const IdentityProfile(id: 'p', name: 'Work', nick: 'w'), ]); @@ -68,10 +66,7 @@ void main() { expect(restored.single.name, 'N'); expect(restored.single.password, isNull); expect((await settings2.loadSettings()).monospaceMessages, isTrue); - expect( - (await profiles2.loadProfiles()).any((p) => p.id == 'p'), - isTrue, - ); + expect((await profiles2.loadProfiles()).any((p) => p.id == 'p'), isTrue); }); test('rejects a non-object backup', () async { diff --git a/test/certificate_store_test.dart b/test/certificate_store_test.dart index ee3c261..eb2d627 100644 --- a/test/certificate_store_test.dart +++ b/test/certificate_store_test.dart @@ -4,10 +4,12 @@ import 'package:androidircx/core/security/secret_storage.dart'; import 'package:androidircx/core/storage/network_secret_keys.dart'; import 'package:flutter_test/flutter_test.dart'; -const _certPem = '-----BEGIN CERTIFICATE-----\n' +const _certPem = + '-----BEGIN CERTIFICATE-----\n' 'MIIBmockCertBody0123456789ABCDEFabcdef+/==\n' '-----END CERTIFICATE-----'; -const _keyPem = '-----BEGIN PRIVATE KEY-----\n' +const _keyPem = + '-----BEGIN PRIVATE KEY-----\n' 'MIIEmockKeyBody0123456789ABCDEFabcdef+/==\n' '-----END PRIVATE KEY-----'; @@ -19,7 +21,10 @@ void main() { await store.save( 'net-1', - const ClientCertificate(certificatePem: _certPem, privateKeyPem: _keyPem), + const ClientCertificate( + certificatePem: _certPem, + privateKeyPem: _keyPem, + ), ); expect(await store.has('net-1'), isTrue); @@ -96,7 +101,8 @@ void main() { () => validateClientCertificate( const ClientCertificate( certificatePem: _certPem, - privateKeyPem: '-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----', + privateKeyPem: + '-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----', ), ), throwsA(isA()), @@ -104,11 +110,15 @@ void main() { }); test('accepts RSA and EC private-key PEM labels', () { - const rsaKey = '-----BEGIN RSA PRIVATE KEY-----\nAAAABBBBCCCC+/==\n' + const rsaKey = + '-----BEGIN RSA PRIVATE KEY-----\nAAAABBBBCCCC+/==\n' '-----END RSA PRIVATE KEY-----'; expect( () => validateClientCertificate( - const ClientCertificate(certificatePem: _certPem, privateKeyPem: rsaKey), + const ClientCertificate( + certificatePem: _certPem, + privateKeyPem: rsaKey, + ), ), returnsNormally, ); @@ -142,7 +152,10 @@ void main() { ); await store.save( 'net-1', - const ClientCertificate(certificatePem: _certPem, privateKeyPem: _keyPem), + const ClientCertificate( + certificatePem: _certPem, + privateKeyPem: _keyPem, + ), ); final read = await store.read('net-1'); expect(read!.isPkcs12, isFalse); diff --git a/test/channel_settings_test.dart b/test/channel_settings_test.dart new file mode 100644 index 0000000..da3c626 --- /dev/null +++ b/test/channel_settings_test.dart @@ -0,0 +1,108 @@ +import 'dart:async'; + +import 'package:androidircx/core/models/network_config.dart'; +import 'package:androidircx/core/storage/in_memory_network_repository.dart'; +import 'package:androidircx/features/chat/application/chat_session_controller.dart'; +import 'package:androidircx/features/chat/data/channel_notes_repository.dart'; +import 'package:androidircx/features/chat/presentation/channel_settings_screen.dart'; +import 'package:androidircx/features/chat/presentation/join_channel_dialog.dart'; +import 'package:androidircx/features/connections/application/network_list_controller.dart'; +import 'package:androidircx/irc/services/irc_service.dart'; +import 'package:androidircx/irc/services/irc_transport.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _FakeTransport implements IrcTransport { + final StreamController _controller = + StreamController.broadcast(); + + @override + Stream get lines => _controller.stream; + + @override + Future close() async { + await _controller.close(); + } + + @override + Future sendLine(String line) async {} + + void emit(String line) { + _controller.add(line); + } +} + +void main() { + testWidgets('channel settings edits auto-join and note', (tester) async { + SharedPreferences.setMockInitialValues({}); + const network = NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.dbase.in.rs', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + ); + final transport = _FakeTransport(); + final controller = ChatSessionController( + network: network, + ircService: IrcService(transportConnector: (_) async => transport), + ); + final repository = InMemoryNetworkRepository([network]); + final networkController = NetworkListController(repository: repository); + + await controller.start(); + await controller.joinChannel(const JoinChannelRequest(channel: '#room')); + transport.emit(':server 332 AndroidIRCX #room :Welcome to the room'); + transport.emit(':alice!user@example PRIVMSG #room :hi all'); + // Flush the stream microtasks inside the fake-async test zone. + await tester.idle(); + + final tab = controller.tabs.firstWhere((item) => item.name == '#room'); + + await tester.pumpWidget( + MaterialApp( + home: ChannelSettingsScreen( + controller: controller, + tab: tab, + networkController: networkController, + notesRepository: ChannelNotesRepository(), + ), + ), + ); + await tester.pump(); + await tester.pump(); + + // Topic and recent log are shown. + expect(find.text('Welcome to the room'), findsOneWidget); + expect(find.textContaining('hi all'), findsWidgets); + + // Toggle auto-join and check the stored network config. + await tester.tap(find.byKey(const Key('channel-settings-auto-join'))); + await tester.pumpAndSettle(); + var saved = (await repository.loadNetworks()).single; + expect(saved.autoJoinChannels, contains('#room')); + + // Save a note and read it back through the repository. + await tester.enterText( + find.byKey(const Key('channel-settings-note')), + 'ops meeting every friday', + ); + await tester.tap(find.byKey(const Key('channel-settings-save-note'))); + await tester.pumpAndSettle(); + expect( + await ChannelNotesRepository().getNote('dbase', '#room'), + 'ops meeting every friday', + ); + + // Toggle auto-join back off. + await tester.tap(find.byKey(const Key('channel-settings-auto-join'))); + await tester.pumpAndSettle(); + saved = (await repository.loadNetworks()).single; + expect(saved.autoJoinChannels, isNot(contains('#room'))); + + controller.dispose(); + networkController.dispose(); + }); +} diff --git a/test/chat_screen_landscape_test.dart b/test/chat_screen_landscape_test.dart index c029392..2aab5ad 100644 --- a/test/chat_screen_landscape_test.dart +++ b/test/chat_screen_landscape_test.dart @@ -114,8 +114,9 @@ void main() { ); await tester.pump(); transports.single.emit(':server 001 AndroidIRCX :Welcome'); - transports.single - .emit(':server NOTICE AndroidIRCX :server message on the tab'); + transports.single.emit( + ':server NOTICE AndroidIRCX :server message on the tab', + ); await tester.pump(); await tester.pump(const Duration(milliseconds: 50)); diff --git a/test/chat_session_controller_test.dart b/test/chat_session_controller_test.dart index a1e539a..f3ac8fd 100644 --- a/test/chat_session_controller_test.dart +++ b/test/chat_session_controller_test.dart @@ -14,6 +14,7 @@ import 'package:androidircx/dcc/services/dcc_service.dart'; import 'package:androidircx/dcc/services/dcc_socket_backend.dart'; import 'package:androidircx/core/storage/settings_repository.dart'; import 'package:androidircx/features/chat/application/chat_session_controller.dart'; +import 'package:androidircx/features/chat/data/channel_notification_rules_repository.dart'; import 'package:androidircx/features/chat/data/chat_session_persistence.dart'; import 'package:androidircx/features/chat/data/message_history_repository.dart'; import 'package:androidircx/features/chat/data/user_list_entry.dart'; @@ -2063,6 +2064,101 @@ void main() { controller.dispose(); }); + test('reactToMessage sends TAGMSG and records the local reaction', () async { + final transport = _FakeTransport(); + final service = IrcService(transportConnector: (_) async => transport); + final controller = ChatSessionController( + network: const NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + ), + ircService: service, + ); + + await controller.start(); + await controller.joinChannel(const JoinChannelRequest(channel: '#room')); + transport.emit('@msgid=react-2 :alice!user@example PRIVMSG #room :Hi'); + await Future.delayed(Duration.zero); + + final message = controller + .messagesForTab( + controller.tabs.firstWhere((tab) => tab.name == '#room').id, + ) + .firstWhere((item) => item.tags['msgid'] == 'react-2'); + + final sent = await controller.reactToMessage(message, '👍'); + expect(sent, isTrue); + expect( + transport.sentLines, + contains('@+draft/react=react-2\\:👍 TAGMSG #room'), + ); + expect(controller.reactionsForMessage(message), containsPair('👍', 1)); + + // Messages without a msgid cannot be reacted to. + transport.emit(':alice!user@example PRIVMSG #room :No msgid here'); + await Future.delayed(Duration.zero); + final plain = controller + .messagesForTab( + controller.tabs.firstWhere((tab) => tab.name == '#room').id, + ) + .lastWhere((item) => item.content == 'No msgid here'); + expect(await controller.reactToMessage(plain, '👍'), isFalse); + + controller.dispose(); + }); + + test( + 'queues messages typed while disconnected and flushes on connect', + () async { + final transport = _FakeTransport(); + final service = IrcService(transportConnector: (_) async => transport); + final controller = ChatSessionController( + network: const NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + ), + ircService: service, + ); + + // Open a query tab and type while still disconnected. + await controller.handleComposerSubmit('/query alice'); + await controller.handleComposerSubmit('first offline'); + await controller.handleComposerSubmit('second offline'); + expect(controller.queuedOfflineMessages, 2); + expect( + transport.sentLines.where((line) => line.contains('offline')), + isEmpty, + ); + + await controller.start(); + transport.emit(':server 001 AndroidIRCX :Welcome'); + // Post-registration actions (including the outbox flush) run at the + // end of the MOTD. + transport.emit(':server 422 AndroidIRCX :MOTD File is missing'); + await Future.delayed(Duration.zero); + await Future.delayed(Duration.zero); + + expect(controller.queuedOfflineMessages, 0); + final sent = transport.sentLines + .where((line) => line.startsWith('PRIVMSG alice')) + .toList(); + expect(sent, [ + 'PRIVMSG alice :first offline', + 'PRIVMSG alice :second offline', + ]); + + controller.dispose(); + }, + ); + test('handles account away host and setname user-state frames', () async { final transport = _FakeTransport(); final service = IrcService(transportConnector: (_) async => transport); @@ -2931,6 +3027,88 @@ void main() { controller.dispose(); }); + test('per-channel notification rules mute, filter, and promote', () async { + final transport = _FakeTransport(); + final service = IrcService(transportConnector: (_) async => transport); + final controller = ChatSessionController( + network: const NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + ), + ircService: service, + settingsRepository: _FakeSettingsRepository( + const AppSettings( + highlightWords: ['flutter'], + notificationsEnabled: true, + ), + ), + ); + + final received = []; + final sub = controller.notifications.listen(received.add); + await controller.start(); + + // Regular channel chatter does not notify by default. + transport.emit(':alice!u@h PRIVMSG #room :plain chatter'); + await Future.delayed(Duration.zero); + expect(received, isEmpty); + + // "All messages" promotes regular channel chat to a notification. + await controller.setChannelNotificationRule( + '#room', + ChannelNotificationRule.all, + ); + transport.emit(':alice!u@h PRIVMSG #room :more chatter'); + await Future.delayed(Duration.zero); + expect(received, hasLength(1)); + + // "Mentions only" drops plain chatter but keeps highlights. + await controller.setChannelNotificationRule( + '#room', + ChannelNotificationRule.mentionsOnly, + ); + transport.emit(':alice!u@h PRIVMSG #room :still chatter'); + transport.emit(':alice!u@h PRIVMSG #room :flutter rocks'); + await Future.delayed(Duration.zero); + expect(received, hasLength(2)); + expect( + received.last.channelKind, + ForegroundNotificationChannelKind.highlights, + ); + + // "Muted" suppresses even highlights. + await controller.setChannelNotificationRule( + '#room', + ChannelNotificationRule.mute, + ); + transport.emit(':alice!u@h PRIVMSG #room :flutter again'); + await Future.delayed(Duration.zero); + expect(received, hasLength(2)); + + // Rules persist per network and reload into a fresh controller. + final reloaded = ChatSessionController( + network: const NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.example.test', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + ), + ircService: IrcService(transportConnector: (_) async => _FakeTransport()), + ); + await reloaded.start(); + expect(reloaded.notificationRuleFor('#ROOM'), ChannelNotificationRule.mute); + + await sub.cancel(); + controller.dispose(); + reloaded.dispose(); + }); + test('suppresses notifications disabled in settings', () async { final transport = _FakeTransport(); final service = IrcService(transportConnector: (_) async => transport); diff --git a/test/command_aliases_test.dart b/test/command_aliases_test.dart new file mode 100644 index 0000000..bbf7ecf --- /dev/null +++ b/test/command_aliases_test.dart @@ -0,0 +1,98 @@ +import 'package:androidircx/features/chat/application/command_service.dart'; +import 'package:androidircx/features/settings/presentation/command_aliases_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + group('CommandService aliases', () { + test('custom alias expands in normalizeCommand and persists', () async { + final service = CommandService(); + await service.load(); + + expect(await service.setAlias('gm', '/msg GameMaster'), isNull); + expect( + service.normalizeCommand('/gm hello there'), + '/msg GameMaster hello there', + ); + + // A fresh service instance sees the persisted alias. + final reloaded = CommandService(); + await reloaded.load(); + expect(reloaded.normalizeCommand('/gm hi'), '/msg GameMaster hi'); + expect(reloaded.isCustomAlias('gm'), isTrue); + }); + + test( + 'custom alias can shadow a built-in and removal restores it', + () async { + final service = CommandService(); + await service.load(); + + expect(service.normalizeCommand('/j #a'), '/join #a'); + expect(await service.setAlias('j', '/join #androidircx'), isNull); + expect(service.normalizeCommand('/j'), '/join #androidircx'); + + await service.removeAlias('j'); + expect(service.normalizeCommand('/j #a'), '/join #a'); + expect(service.isCustomAlias('j'), isFalse); + expect(service.isBuiltInAlias('j'), isTrue); + }, + ); + + test('rejects invalid aliases and command-name collisions', () async { + final service = CommandService(); + await service.load(); + + expect(await service.setAlias('two words', '/join'), isNotNull); + expect(await service.setAlias('', '/join'), isNotNull); + // 'join' is an actual command, not allowed as an alias. + expect(await service.setAlias('join', '/part'), isNotNull); + // Command without leading slash gets normalized instead of rejected. + expect(await service.setAlias('gg', 'msg GG'), isNull); + expect(service.normalizeCommand('/gg'), '/msg GG'); + }); + }); + + group('CommandAliasesScreen', () { + testWidgets('adds and removes a custom alias', (tester) async { + final service = CommandService(); + await tester.pumpWidget( + MaterialApp(home: CommandAliasesScreen(commandService: service)), + ); + await tester.pumpAndSettle(); + + // Built-in aliases are listed without a remove button. + expect(find.text('/j'), findsOneWidget); + expect(find.byKey(const Key('alias-remove-j')), findsNothing); + + await tester.tap(find.byKey(const Key('alias-add'))); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const Key('alias-name-field')), 'gm'); + await tester.enterText( + find.byKey(const Key('alias-command-field')), + '/msg GameMaster', + ); + await tester.tap(find.byKey(const Key('alias-save'))); + await tester.pumpAndSettle(); + + expect(find.text('/gm'), findsOneWidget); + expect(service.normalizeCommand('/gm hi'), '/msg GameMaster hi'); + + await tester.scrollUntilVisible( + find.byKey(const Key('alias-remove-gm')), + 150, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap(find.byKey(const Key('alias-remove-gm'))); + await tester.pumpAndSettle(); + expect(find.text('/gm'), findsNothing); + }); + }); +} diff --git a/test/crash_reporter_test.dart b/test/crash_reporter_test.dart index 5aa65d4..28aef38 100644 --- a/test/crash_reporter_test.dart +++ b/test/crash_reporter_test.dart @@ -143,20 +143,24 @@ void main() { expect(await reporter.loadReports(), isEmpty); }); - test('builds a mailto uri addressed to the contact with subject/body', - () async { - final reporter = build(); - final report = await reporter.record( - Exception('kaboom'), - null, - source: 'test', - ); - final uri = reporter.buildMailtoUri(report!); - expect(uri.scheme, 'mailto'); - expect(uri.path, 'contact@androidircx.com'); - expect(uri.query, contains('subject=AndroidIRCX%20Crash%20Report')); - expect(Uri.decodeComponent(uri.queryParameters['body']!), - contains('kaboom')); - }); + test( + 'builds a mailto uri addressed to the contact with subject/body', + () async { + final reporter = build(); + final report = await reporter.record( + Exception('kaboom'), + null, + source: 'test', + ); + final uri = reporter.buildMailtoUri(report!); + expect(uri.scheme, 'mailto'); + expect(uri.path, 'contact@androidircx.com'); + expect(uri.query, contains('subject=AndroidIRCX%20Crash%20Report')); + expect( + Uri.decodeComponent(uri.queryParameters['body']!), + contains('kaboom'), + ); + }, + ); }); } diff --git a/test/crash_reports_screen_test.dart b/test/crash_reports_screen_test.dart index eb64875..1dcb4eb 100644 --- a/test/crash_reports_screen_test.dart +++ b/test/crash_reports_screen_test.dart @@ -62,9 +62,7 @@ void main() { final r = reporter(); await r.record(Exception('x'), null, source: 'test'); - await tester.pumpWidget( - MaterialApp(home: CrashReportsScreen(reporter: r)), - ); + await tester.pumpWidget(MaterialApp(home: CrashReportsScreen(reporter: r))); await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('crash-reports-clear'))); diff --git a/test/ctcp_test.dart b/test/ctcp_test.dart index 7d91253..d3e3b06 100644 --- a/test/ctcp_test.dart +++ b/test/ctcp_test.dart @@ -27,9 +27,6 @@ void main() { }); test('encodes CTCP command', () { - expect( - encodeCtcp('ping', '123'), - '\u0001PING 123\u0001', - ); + expect(encodeCtcp('ping', '123'), '\u0001PING 123\u0001'); }); } diff --git a/test/history_encryption_key_manager_test.dart b/test/history_encryption_key_manager_test.dart index 6719169..99f3898 100644 --- a/test/history_encryption_key_manager_test.dart +++ b/test/history_encryption_key_manager_test.dart @@ -49,19 +49,21 @@ void main() { expect(base64Decode(key!).length, 32); }); - test('returns null and provisions nothing when authentication fails', - () async { - final storage = InMemorySecretStorage(); - final manager = HistoryEncryptionKeyManager( - storage: storage, - authenticator: _FakeAuthenticator(false), - ); - - final key = await manager.unlockKey(); - expect(key, isNull); - expect(await manager.hasKey(), isFalse); - expect(await storage.getAllSecretKeys(), isEmpty); - }); + test( + 'returns null and provisions nothing when authentication fails', + () async { + final storage = InMemorySecretStorage(); + final manager = HistoryEncryptionKeyManager( + storage: storage, + authenticator: _FakeAuthenticator(false), + ); + + final key = await manager.unlockKey(); + expect(key, isNull); + expect(await manager.hasKey(), isFalse); + expect(await storage.getAllSecretKeys(), isEmpty); + }, + ); test('does not release an existing key without authentication', () async { final storage = InMemorySecretStorage(); @@ -80,18 +82,20 @@ void main() { expect(await manager.hasKey(), isTrue); }); - test('resetKey discards the key so history can no longer be opened', - () async { - final manager = HistoryEncryptionKeyManager( - storage: InMemorySecretStorage(), - authenticator: _FakeAuthenticator(true), - ); - await manager.unlockKey(); - expect(await manager.hasKey(), isTrue); - - await manager.resetKey(); - expect(await manager.hasKey(), isFalse); - }); + test( + 'resetKey discards the key so history can no longer be opened', + () async { + final manager = HistoryEncryptionKeyManager( + storage: InMemorySecretStorage(), + authenticator: _FakeAuthenticator(true), + ); + await manager.unlockKey(); + expect(await manager.hasKey(), isTrue); + + await manager.resetKey(); + expect(await manager.hasKey(), isFalse); + }, + ); test('passes the unlock reason through to the authenticator', () async { final auth = _FakeAuthenticator(true); diff --git a/test/history_payload_cipher_test.dart b/test/history_payload_cipher_test.dart index 5dff3fd..7b1bdf3 100644 --- a/test/history_payload_cipher_test.dart +++ b/test/history_payload_cipher_test.dart @@ -15,14 +15,17 @@ void main() { expect(await codec.decrypt(cipher), 'hello #room secret message'); }); - test('uses a fresh random nonce so equal plaintext differs on disk', () async { - final codec = AesGcmHistoryPayloadCodec.fromBase64Key(key); - final a = await codec.encrypt('same'); - final b = await codec.encrypt('same'); - expect(a, isNot(b)); - expect(await codec.decrypt(a), 'same'); - expect(await codec.decrypt(b), 'same'); - }); + test( + 'uses a fresh random nonce so equal plaintext differs on disk', + () async { + final codec = AesGcmHistoryPayloadCodec.fromBase64Key(key); + final a = await codec.encrypt('same'); + final b = await codec.encrypt('same'); + expect(a, isNot(b)); + expect(await codec.decrypt(a), 'same'); + expect(await codec.decrypt(b), 'same'); + }, + ); test('a wrong key cannot decrypt (tamper/theft protection)', () async { final codec = AesGcmHistoryPayloadCodec.fromBase64Key(key); diff --git a/test/identity_profile_test.dart b/test/identity_profile_test.dart index 9686557..f2c6f08 100644 --- a/test/identity_profile_test.dart +++ b/test/identity_profile_test.dart @@ -38,7 +38,10 @@ void main() { test('exposes the built-in default identity', () { expect(IdentityProfile.defaultProfile.nick, 'AndroidIRCX'); - expect(IdentityProfile.defaultProfile.id, IdentityProfile.defaultProfileId); + expect( + IdentityProfile.defaultProfile.id, + IdentityProfile.defaultProfileId, + ); }); }); @@ -87,27 +90,31 @@ void main() { expect(normalized.map((p) => p.id), contains('p1')); }); - test('in-memory repo saves, replaces, and deletes custom profiles', () async { - final repo = InMemoryIdentityProfileRepository(); - const profile = IdentityProfile(id: 'p1', name: 'Work', nick: 'alice'); - await repo.saveProfile(profile); - expect((await repo.loadProfiles()).any((p) => p.id == 'p1'), isTrue); + test( + 'in-memory repo saves, replaces, and deletes custom profiles', + () async { + final repo = InMemoryIdentityProfileRepository(); + const profile = IdentityProfile(id: 'p1', name: 'Work', nick: 'alice'); + await repo.saveProfile(profile); + expect((await repo.loadProfiles()).any((p) => p.id == 'p1'), isTrue); - await repo.saveProfile(profile.copyWith(nick: 'alice2')); - final loaded = await repo.loadProfiles(); - expect(loaded.firstWhere((p) => p.id == 'p1').nick, 'alice2'); - expect(loaded.where((p) => p.id == 'p1'), hasLength(1)); + await repo.saveProfile(profile.copyWith(nick: 'alice2')); + final loaded = await repo.loadProfiles(); + expect(loaded.firstWhere((p) => p.id == 'p1').nick, 'alice2'); + expect(loaded.where((p) => p.id == 'p1'), hasLength(1)); - await repo.deleteProfile('p1'); - expect((await repo.loadProfiles()).any((p) => p.id == 'p1'), isFalse); - }); + await repo.deleteProfile('p1'); + expect((await repo.loadProfiles()).any((p) => p.id == 'p1'), isFalse); + }, + ); test('the default profile cannot be deleted', () async { final repo = InMemoryIdentityProfileRepository(); await repo.deleteProfile(IdentityProfile.defaultProfileId); expect( - (await repo.loadProfiles()) - .any((p) => p.id == IdentityProfile.defaultProfileId), + (await repo.loadProfiles()).any( + (p) => p.id == IdentityProfile.defaultProfileId, + ), isTrue, ); }); diff --git a/test/irc_encoding_test.dart b/test/irc_encoding_test.dart new file mode 100644 index 0000000..1e1083a --- /dev/null +++ b/test/irc_encoding_test.dart @@ -0,0 +1,226 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:androidircx/core/models/network_config.dart'; +import 'package:androidircx/irc/encoding/irc_encoding.dart'; +import 'package:androidircx/irc/services/irc_transport_connector_io.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('normalizeIrcEncoding', () { + test('normalizes case and whitespace', () { + expect(normalizeIrcEncoding(' Windows-1250 '), 'windows-1250'); + expect(normalizeIrcEncoding('UTF-8'), 'utf-8'); + }); + + test('falls back to utf-8 for unknown or empty labels', () { + expect(normalizeIrcEncoding('shift_jis'), 'utf-8'); + expect(normalizeIrcEncoding(''), 'utf-8'); + expect(normalizeIrcEncoding(null), 'utf-8'); + }); + + test('display name resolves for supported labels', () { + expect(ircEncodingDisplayName('windows-1251'), 'Cyrillic (Windows-1251)'); + expect(ircEncodingDisplayName('bogus'), 'UTF-8 (Unicode)'); + }); + }); + + group('decodeIrcLine', () { + test('decodes utf-8 by default and tolerates malformed bytes', () { + expect(decodeIrcLine(utf8.encode('Žčć šđ')), 'Žčć šđ'); + // Lone 0xE8 is invalid UTF-8; must not throw. + expect(decodeIrcLine([0x61, 0xE8, 0x62]), contains('a')); + }); + + test('decodes windows-1250 line bytes', () { + // 'čađ' in windows-1250: č=0xE8 a=0x61 đ=0xF0 + final decoded = decodeIrcLine([ + 0xE8, + 0x61, + 0xF0, + ], encoding: 'windows-1250'); + expect(decoded, 'čađ'); + }); + + test('decodes windows-1251 cyrillic line bytes', () { + // 'Жао' in windows-1251: Ж=0xC6 а=0xE0 о=0xEE + final decoded = decodeIrcLine([ + 0xC6, + 0xE0, + 0xEE, + ], encoding: 'windows-1251'); + expect(decoded, 'Жао'); + }); + + test('utf8Fallback decodes valid UTF-8 as UTF-8', () { + final utf8Bytes = utf8.encode('šđž'); + final decoded = decodeIrcLine( + utf8Bytes, + encoding: 'windows-1250', + utf8Fallback: true, + ); + expect(decoded, 'šđž'); + }); + + test('utf8Fallback falls back to legacy for invalid UTF-8', () { + final decoded = decodeIrcLine( + [0xE8, 0x61, 0xF0], + encoding: 'windows-1250', + utf8Fallback: true, + ); + expect(decoded, 'čađ'); + }); + }); + + group('encodeIrcLine', () { + test('returns null for utf-8 (caller uses the string path)', () { + expect(encodeIrcLine('PRIVMSG #a :hi'), isNull); + expect(encodeIrcLine('PRIVMSG #a :hi', encoding: 'utf-8'), isNull); + }); + + test('returns null when utf8Fallback prefers sending UTF-8', () { + expect( + encodeIrcLine('hi', encoding: 'windows-1250', utf8Fallback: true), + isNull, + ); + }); + + test('encodes legacy charsets to legacy bytes', () { + expect(encodeIrcLine('čađ', encoding: 'windows-1250'), [ + 0xE8, + 0x61, + 0xF0, + ]); + expect(encodeIrcLine('Жао', encoding: 'windows-1251'), [ + 0xC6, + 0xE0, + 0xEE, + ]); + }); + + test('round-trips a legacy encode/decode', () { + const original = 'Šta ima, đače?'; + final bytes = encodeIrcLine(original, encoding: 'windows-1250')!; + expect(decodeIrcLine(bytes, encoding: 'windows-1250'), original); + }); + }); + + group('IrcByteLineSplitter', () { + test('splits CRLF and LF lines and strips the CR', () { + final splitter = IrcByteLineSplitter(); + final lines = splitter.addChunk(utf8.encode('a\r\nb\nc')); + expect(lines, [utf8.encode('a'), utf8.encode('b')]); + expect(splitter.addChunk(utf8.encode('\r\n')), [utf8.encode('c')]); + }); + + test('keeps multi-byte characters intact across chunk boundaries', () { + final splitter = IrcByteLineSplitter(); + final bytes = utf8.encode('PRIVMSG #x :šđž\r\n'); + // Feed one byte at a time so the UTF-8 sequences straddle chunks. + final lines = >[]; + for (final byte in bytes) { + lines.addAll(splitter.addChunk([byte])); + } + expect(lines, hasLength(1)); + expect(utf8.decode(lines.single), 'PRIVMSG #x :šđž'); + }); + + test('buffers partial lines until the newline arrives', () { + final splitter = IrcByteLineSplitter(); + expect(splitter.addChunk(utf8.encode('PING :tok')), isEmpty); + expect(splitter.addChunk(utf8.encode('en\r\nNOTICE')), [ + utf8.encode('PING :token'), + ]); + }); + }); + + group('NetworkConfig encoding fields', () { + test('defaults to utf-8 without fallback', () { + const network = NetworkConfig( + id: 'n1', + name: 'Net', + host: 'irc.example.org', + port: 6697, + nickname: 'nick', + ); + expect(network.encoding, 'utf-8'); + expect(network.encodingUtf8Fallback, isFalse); + }); + + test('round-trips through JSON', () { + const network = NetworkConfig( + id: 'n1', + name: 'Net', + host: 'irc.example.org', + port: 6697, + nickname: 'nick', + encoding: 'windows-1250', + encodingUtf8Fallback: true, + ); + final restored = NetworkConfig.fromJson(network.toJson()); + expect(restored.encoding, 'windows-1250'); + expect(restored.encodingUtf8Fallback, isTrue); + }); + + test('missing JSON fields keep utf-8 defaults', () { + final restored = NetworkConfig.fromJson({ + 'id': 'n1', + 'name': 'Net', + 'host': 'irc.example.org', + 'port': 6697, + 'nickname': 'nick', + }); + expect(restored.encoding, 'utf-8'); + expect(restored.encodingUtf8Fallback, isFalse); + }); + }); + + group('SocketIrcTransport encoding', () { + test('decodes incoming legacy bytes and encodes outgoing lines', () async { + final server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + addTearDown(() async => server.close()); + + final serverReceived = []; + final serverDone = server.first.then((client) async { + // 'čao\r\n' in windows-1250. + client.add([0xE8, 0x61, 0x6F, 0x0D, 0x0A]); + await client.flush(); + await for (final chunk in client) { + serverReceived.addAll(chunk); + if (serverReceived.contains(0x0A)) { + break; + } + } + client.destroy(); + }); + + final network = NetworkConfig( + id: 'n1', + name: 'Net', + host: InternetAddress.loopbackIPv4.address, + port: server.port, + nickname: 'nick', + useTls: false, + encoding: 'windows-1250', + ); + + final transport = await SocketIrcTransport.connect(network); + addTearDown(() async => transport.close()); + + final firstLine = transport.lines.first; + await transport.sendLine('PRIVMSG #x :čao'); + expect(await firstLine, 'čao'); + + await serverDone; + // Outgoing 'č' must be one windows-1250 byte (0xE8), not UTF-8. + expect(serverReceived, [ + ...ascii.encode('PRIVMSG #x :'), + 0xE8, + 0x61, + 0x6F, + 0x0D, + 0x0A, + ]); + }); + }); +} diff --git a/test/irc_transport_web_framing_test.dart b/test/irc_transport_web_framing_test.dart index d74cba8..27025a5 100644 --- a/test/irc_transport_web_framing_test.dart +++ b/test/irc_transport_web_framing_test.dart @@ -7,22 +7,24 @@ import 'package:flutter_test/flutter_test.dart'; void main() { group('IRCv3 WebSocket framing', () { - test('decodes a single text frame without a trailing CRLF into one line', - () { - expect(WebIrcTransport.framesFromMessage('PING :abc'), ['PING :abc']); - }); + test( + 'decodes a single text frame without a trailing CRLF into one line', + () { + expect(WebIrcTransport.framesFromMessage('PING :abc'), ['PING :abc']); + }, + ); test('strips an optional trailing CRLF from a text frame', () { expect(WebIrcTransport.framesFromMessage('PING :abc\r\n'), ['PING :abc']); }); test('decodes a binary (byte) frame as UTF-8', () { - final bytes = - Uint8List.fromList(utf8.encode(':nick!u@h PRIVMSG #c :héllo\r\n')); - expect( - WebIrcTransport.framesFromMessage(bytes), - [':nick!u@h PRIVMSG #c :héllo'], + final bytes = Uint8List.fromList( + utf8.encode(':nick!u@h PRIVMSG #c :héllo\r\n'), ); + expect(WebIrcTransport.framesFromMessage(bytes), [ + ':nick!u@h PRIVMSG #c :héllo', + ]); }); test('splits a non-compliant frame that packs multiple CRLF lines', () { @@ -60,23 +62,25 @@ void main() { await incoming.close(); }); - test('sendLine terminates each outgoing message with a single CRLF', - () async { - final incoming = StreamController.broadcast(); - final sent = []; - final transport = WebIrcTransport.forTesting( - incoming: incoming.stream, - onSend: sent.add, - ); - - await transport.sendLine('NICK tester'); - await transport.sendLine('USER a 0 * :real'); - - expect(sent, ['NICK tester\r\n', 'USER a 0 * :real\r\n']); - - await transport.close(); - await incoming.close(); - }); + test( + 'sendLine terminates each outgoing message with a single CRLF', + () async { + final incoming = StreamController.broadcast(); + final sent = []; + final transport = WebIrcTransport.forTesting( + incoming: incoming.stream, + onSend: sent.add, + ); + + await transport.sendLine('NICK tester'); + await transport.sendLine('USER a 0 * :real'); + + expect(sent, ['NICK tester\r\n', 'USER a 0 * :real\r\n']); + + await transport.close(); + await incoming.close(); + }, + ); test('closes the line stream when the socket is done', () async { final incoming = StreamController(); diff --git a/test/kick_ban_reasons_test.dart b/test/kick_ban_reasons_test.dart new file mode 100644 index 0000000..7925e76 --- /dev/null +++ b/test/kick_ban_reasons_test.dart @@ -0,0 +1,54 @@ +import 'package:androidircx/features/chat/data/kick_ban_reasons_repository.dart'; +import 'package:androidircx/features/settings/presentation/kick_ban_reasons_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('repository defaults and round-trips custom reasons', () async { + final repository = KickBanReasonsRepository(); + expect( + await repository.loadReasons(), + KickBanReasonsRepository.defaultReasons, + ); + + await repository.saveReasons(['Trolling', ' ', 'Spam ']); + expect(await repository.loadReasons(), ['Trolling', 'Spam']); + }); + + testWidgets('screen adds, removes, and resets reasons', (tester) async { + await tester.pumpWidget(const MaterialApp(home: KickBanReasonsScreen())); + await tester.pumpAndSettle(); + + expect(find.text('Spam'), findsOneWidget); + + await tester.tap(find.byKey(const Key('kick-ban-reason-add'))); + await tester.pumpAndSettle(); + await tester.enterText( + find.byKey(const Key('kick-ban-reason-field')), + 'Trolling', + ); + await tester.tap(find.byKey(const Key('kick-ban-reason-save'))); + await tester.pumpAndSettle(); + expect(find.text('Trolling'), findsOneWidget); + expect( + await KickBanReasonsRepository().loadReasons(), + contains('Trolling'), + ); + + await tester.tap(find.byKey(const Key('kick-ban-reason-remove-Spam'))); + await tester.pumpAndSettle(); + expect(find.text('Spam'), findsNothing); + + await tester.tap(find.byKey(const Key('kick-ban-reasons-reset'))); + await tester.pumpAndSettle(); + expect(find.text('Spam'), findsOneWidget); + expect(find.text('Trolling'), findsNothing); + }); +} diff --git a/test/message_format_test.dart b/test/message_format_test.dart new file mode 100644 index 0000000..7676646 --- /dev/null +++ b/test/message_format_test.dart @@ -0,0 +1,110 @@ +import 'package:androidircx/core/models/app_settings.dart'; +import 'package:androidircx/features/chat/presentation/message_line_format.dart'; +import 'package:androidircx/features/settings/presentation/message_format_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('formatIrcTimestamp', () { + final afternoon = DateTime(2026, 1, 1, 13, 5, 9); + final midnight = DateTime(2026, 1, 1, 0, 5, 9); + final noon = DateTime(2026, 1, 1, 12, 5, 9); + + test('formats 24-hour patterns', () { + expect(formatIrcTimestamp(afternoon, 'HH:mm'), '13:05'); + expect(formatIrcTimestamp(afternoon, 'HH:mm:ss'), '13:05:09'); + }); + + test('formats 12-hour patterns with midnight/noon edges', () { + expect(formatIrcTimestamp(afternoon, 'h:mm a'), '1:05 PM'); + expect(formatIrcTimestamp(afternoon, 'h:mm:ss a'), '1:05:09 PM'); + expect(formatIrcTimestamp(midnight, 'h:mm a'), '12:05 AM'); + expect(formatIrcTimestamp(noon, 'h:mm a'), '12:05 PM'); + }); + + test('unknown pattern falls back to 24-hour HH:mm', () { + expect(formatIrcTimestamp(afternoon, 'bogus'), '13:05'); + }); + }); + + group('formatNickLabel', () { + test('applies each decoration style', () { + expect(formatNickLabel('alice', NickDisplayFormat.plain), 'alice'); + expect(formatNickLabel('alice', NickDisplayFormat.angle), ''); + expect(formatNickLabel('alice', NickDisplayFormat.colon), 'alice:'); + expect(formatNickLabel('alice', NickDisplayFormat.bracket), '[alice]'); + }); + }); + + group('AppSettings message format fields', () { + test('round-trip through JSON', () { + const settings = AppSettings( + timestampFormat: 'h:mm a', + timestampPosition: TimestampPosition.beforeNick, + nickDisplayFormat: NickDisplayFormat.angle, + ); + final restored = AppSettings.fromJson(settings.toJson()); + expect(restored.timestampFormat, 'h:mm a'); + expect(restored.timestampPosition, TimestampPosition.beforeNick); + expect(restored.nickDisplayFormat, NickDisplayFormat.angle); + }); + + test('missing JSON fields keep defaults', () { + final restored = AppSettings.fromJson(const {}); + expect(restored.timestampFormat, 'HH:mm'); + expect(restored.timestampPosition, TimestampPosition.afterNick); + expect(restored.nickDisplayFormat, NickDisplayFormat.plain); + }); + }); + + group('MessageFormatScreen', () { + testWidgets('edits timestamp and nick style with live preview', ( + tester, + ) async { + var settings = const AppSettings(); + await tester.pumpWidget( + MaterialApp( + home: MessageFormatScreen( + initialSettings: settings, + onSettingsChanged: (next) => settings = next, + ), + ), + ); + + // Preview renders the default nick style and 24-hour clock. + expect(find.text('Message format'), findsOneWidget); + expect(find.textContaining('alice', findRichText: true), findsWidgets); + + await tester.tap( + find.byKey(const Key('message-format-timestamp-format')), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('12-hour (1:05 PM)').last); + await tester.pumpAndSettle(); + expect(settings.timestampFormat, 'h:mm a'); + + await tester.tap( + find.byKey(const Key('message-format-timestamp-position')), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Before nick').last); + await tester.pumpAndSettle(); + expect(settings.timestampPosition, TimestampPosition.beforeNick); + + await tester.tap(find.byKey(const Key('message-format-nick-style'))); + await tester.pumpAndSettle(); + await tester.tap(find.text('').last); + await tester.pumpAndSettle(); + expect(settings.nickDisplayFormat, NickDisplayFormat.angle); + + // Hiding timestamps removes the format/position controls. + await tester.tap(find.byKey(const Key('message-format-show-timestamps'))); + await tester.pumpAndSettle(); + expect(settings.showTimestamps, isFalse); + expect( + find.byKey(const Key('message-format-timestamp-format')), + findsNothing, + ); + }); + }); +} diff --git a/test/mirc_preset_parser_test.dart b/test/mirc_preset_parser_test.dart index b43375b..ea3770d 100644 --- a/test/mirc_preset_parser_test.dart +++ b/test/mirc_preset_parser_test.dart @@ -5,7 +5,9 @@ import 'package:flutter_test/flutter_test.dart'; void main() { test('decodes utf8 mirc preset base64', () { - final decoded = decodeMircPresetBase64(base64.encode(utf8.encode('line1\nline2'))); + final decoded = decodeMircPresetBase64( + base64.encode(utf8.encode('line1\nline2')), + ); expect(decoded, 'line1\nline2'); }); diff --git a/test/network_form_cert_import_test.dart b/test/network_form_cert_import_test.dart index 8f0f26e..e1bb39d 100644 --- a/test/network_form_cert_import_test.dart +++ b/test/network_form_cert_import_test.dart @@ -15,8 +15,7 @@ class _FakePicker implements DccFilePicker { const _cert = '-----BEGIN CERTIFICATE-----\nMIIByyCERT\n-----END CERTIFICATE-----'; -const _key = - '-----BEGIN PRIVATE KEY-----\nMIIEvKEY\n-----END PRIVATE KEY-----'; +const _key = '-----BEGIN PRIVATE KEY-----\nMIIEvKEY\n-----END PRIVATE KEY-----'; void main() { setUp(() => SharedPreferences.setMockInitialValues({})); @@ -78,10 +77,7 @@ void main() { await tester.tap(find.byKey(const Key('network-form-import-cert'))); await tester.pumpAndSettle(); - expect( - find.byKey(const Key('network-form-pkcs12-loaded')), - findsOneWidget, - ); + expect(find.byKey(const Key('network-form-pkcs12-loaded')), findsOneWidget); expect(find.text('PKCS#12 bundle loaded'), findsOneWidget); }); } diff --git a/test/notification_permission_settings_test.dart b/test/notification_permission_settings_test.dart index 277707a..2c1e7c4 100644 --- a/test/notification_permission_settings_test.dart +++ b/test/notification_permission_settings_test.dart @@ -26,6 +26,19 @@ class _FakePermissions implements AppPermissions { return notifResult; } + bool hasBattery = false; + int batteryRequests = 0; + + @override + Future hasIgnoreBatteryOptimizations() async => hasBattery; + + @override + Future requestIgnoreBatteryOptimizations() async { + batteryRequests++; + hasBattery = true; + return AppPermissionResult.granted; + } + @override Future openSettingsPage() async {} } @@ -50,18 +63,20 @@ void main() { await tester.pumpAndSettle(); } - testWidgets('enabling notifications requests permission and enables on grant', - (tester) async { - final perms = _FakePermissions(notifResult: AppPermissionResult.granted); - await pump(tester, perms); + testWidgets( + 'enabling notifications requests permission and enables on grant', + (tester) async { + final perms = _FakePermissions(notifResult: AppPermissionResult.granted); + await pump(tester, perms); - await tester.tap(find.byKey(const Key('settings-notifications-enabled'))); - await tester.pumpAndSettle(); + await tester.tap(find.byKey(const Key('settings-notifications-enabled'))); + await tester.pumpAndSettle(); - expect(perms.notifRequests, 1); - final saved = await SharedPrefsSettingsRepository().loadSettings(); - expect(saved.notificationsEnabled, isTrue); - }); + expect(perms.notifRequests, 1); + final saved = await SharedPrefsSettingsRepository().loadSettings(); + expect(saved.notificationsEnabled, isTrue); + }, + ); testWidgets('denied permission keeps notifications off', (tester) async { final perms = _FakePermissions(notifResult: AppPermissionResult.denied); @@ -79,11 +94,13 @@ void main() { ); }); - testWidgets('reconciles notifications off when OS permission is missing', - (tester) async { + testWidgets('reconciles notifications off when OS permission is missing', ( + tester, + ) async { // Stored as enabled, but the OS permission is not granted. - await SharedPrefsSettingsRepository() - .saveSettings(const AppSettings(notificationsEnabled: true)); + await SharedPrefsSettingsRepository().saveSettings( + const AppSettings(notificationsEnabled: true), + ); final perms = _FakePermissions(hasNotif: false); await tester.pumpWidget( @@ -123,5 +140,4 @@ void main() { saved = await SharedPrefsSettingsRepository().loadSettings(); expect(saved.analyticsConsent, isTrue); }); - } diff --git a/test/onboarding_permission_test.dart b/test/onboarding_permission_test.dart index 75e257b..9c719db 100644 --- a/test/onboarding_permission_test.dart +++ b/test/onboarding_permission_test.dart @@ -20,6 +20,13 @@ class _FakePermissions implements AppPermissions { @override Future hasNotifications() async => false; + @override + Future hasIgnoreBatteryOptimizations() async => false; + + @override + Future requestIgnoreBatteryOptimizations() async => + AppPermissionResult.granted; + @override Future openSettingsPage() async {} } diff --git a/test/pem_bundle_test.dart b/test/pem_bundle_test.dart index 1928246..d3a6fd8 100644 Binary files a/test/pem_bundle_test.dart and b/test/pem_bundle_test.dart differ diff --git a/test/scram_sha256_session_test.dart b/test/scram_sha256_session_test.dart index 1c04b97..deb3aa9 100644 --- a/test/scram_sha256_session_test.dart +++ b/test/scram_sha256_session_test.dart @@ -139,10 +139,7 @@ void main() { session.createClientFinalMessage( 'r=clientNonceServer,s=c2FsdHlTYWx0,i=4096', ); - expect( - session.validateServerFinalMessage('e=invalid-proof'), - isFalse, - ); + expect(session.validateServerFinalMessage('e=invalid-proof'), isFalse); }); }); } diff --git a/test/server_preset_test.dart b/test/server_preset_test.dart index 6d9b962..9460ff8 100644 --- a/test/server_preset_test.dart +++ b/test/server_preset_test.dart @@ -29,8 +29,9 @@ void main() { }); test('preferredServer prefers a TLS server', () { - final libera = parseServerPresets(_sample) - .firstWhere((p) => p.networkName == 'Libera'); + final libera = parseServerPresets( + _sample, + ).firstWhere((p) => p.networkName == 'Libera'); expect(libera.preferredServer!.hostname, 'irc.libera.chat'); expect(libera.preferredServer!.useSsl, isTrue); expect(libera.preferredServer!.port, 6697); @@ -44,8 +45,9 @@ void main() { group('networkConfigFromPreset', () { test('maps the preferred server into a NetworkConfig', () { - final libera = parseServerPresets(_sample) - .firstWhere((p) => p.networkName == 'Libera'); + final libera = parseServerPresets( + _sample, + ).firstWhere((p) => p.networkName == 'Libera'); final config = networkConfigFromPreset(libera, id: 'net-1'); expect(config.name, 'Libera'); expect(config.host, 'irc.libera.chat'); @@ -55,10 +57,15 @@ void main() { }); test('maps a specific chosen server', () { - final libera = parseServerPresets(_sample) - .firstWhere((p) => p.networkName == 'Libera'); + final libera = parseServerPresets( + _sample, + ).firstWhere((p) => p.networkName == 'Libera'); final plain = libera.servers.firstWhere((s) => !s.useSsl); - final config = networkConfigFromPreset(libera, id: 'net-2', server: plain); + final config = networkConfigFromPreset( + libera, + id: 'net-2', + server: plain, + ); expect(config.host, 'plain.libera.chat'); expect(config.port, 6667); expect(config.useTls, isFalse); @@ -85,9 +92,7 @@ void main() { }); test('falls back to DBase on an empty directory', () async { - final service = ServerPresetService( - httpGet: (_) async => '{"data": []}', - ); + final service = ServerPresetService(httpGet: (_) async => '{"data": []}'); final presets = await service.fetchPresetsOrFallback(); expect(presets.single.networkName, 'DBase'); }); diff --git a/test/sound_service_test.dart b/test/sound_service_test.dart new file mode 100644 index 0000000..56d30cd --- /dev/null +++ b/test/sound_service_test.dart @@ -0,0 +1,151 @@ +import 'package:androidircx/core/sound/sound_service.dart'; +import 'package:androidircx/features/settings/presentation/sound_settings_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _RecordingPlayer implements SoundPlayer { + final List<({String asset, double volume})> played = []; + + @override + Future play(String assetPath, double volume) async { + played.add((asset: assetPath, volume: volume)); + } +} + +class _ThrowingPlayer implements SoundPlayer { + @override + Future play(String assetPath, double volume) async { + throw StateError('audio backend unavailable'); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + group('SoundSettings', () { + test('defaults enable the common events only', () { + const settings = SoundSettings(); + expect(settings.isEventEnabled(SoundEvent.mention), isTrue); + expect(settings.isEventEnabled(SoundEvent.privateMessage), isTrue); + expect(settings.isEventEnabled(SoundEvent.login), isTrue); + expect(settings.isEventEnabled(SoundEvent.disconnect), isTrue); + expect(settings.isEventEnabled(SoundEvent.ring), isTrue); + expect(settings.isEventEnabled(SoundEvent.join), isFalse); + expect(settings.isEventEnabled(SoundEvent.send), isFalse); + }); + + test('round-trips through JSON including event overrides', () { + const settings = SoundSettings(enabled: false, masterVolume: 0.4); + final overridden = settings + .withEvent(SoundEvent.join, true) + .withEvent(SoundEvent.mention, false); + final restored = SoundSettings.fromJson(overridden.toJson()); + expect(restored.enabled, isFalse); + expect(restored.masterVolume, closeTo(0.4, 0.0001)); + expect(restored.isEventEnabled(SoundEvent.join), isTrue); + expect(restored.isEventEnabled(SoundEvent.mention), isFalse); + // Untouched events keep their defaults. + expect(restored.isEventEnabled(SoundEvent.privateMessage), isTrue); + }); + + test('every event has a bundled asset and a label', () { + for (final event in SoundEvent.values) { + expect(soundEventAssets[event], isNotNull, reason: event.name); + expect(soundEventLabels[event], isNotNull, reason: event.name); + } + }); + }); + + group('SoundService', () { + test('plays enabled events at master volume', () async { + final player = _RecordingPlayer(); + final service = SoundService(player: player); + + await service.playEvent(SoundEvent.mention); + + expect(player.played, hasLength(1)); + expect(player.played.single.asset, 'sounds/cuac.wav'); + expect(player.played.single.volume, closeTo(0.7, 0.0001)); + }); + + test('skips disabled events, muted volume, and global off', () async { + final player = _RecordingPlayer(); + final service = SoundService(player: player); + + // join is disabled by default. + await service.playEvent(SoundEvent.join); + expect(player.played, isEmpty); + + await service.updateSettings(service.settings.copyWith(masterVolume: 0)); + await service.playEvent(SoundEvent.mention); + expect(player.played, isEmpty); + + await service.updateSettings( + service.settings.copyWith(enabled: false, masterVolume: 1), + ); + await service.playEvent(SoundEvent.mention); + expect(player.played, isEmpty); + }); + + test('persists and reloads settings', () async { + final service = SoundService(player: _RecordingPlayer()); + await service.updateSettings( + service.settings + .copyWith(masterVolume: 0.3) + .withEvent(SoundEvent.send, true), + ); + + final reloaded = SoundService(player: _RecordingPlayer()); + await reloaded.load(); + expect(reloaded.settings.masterVolume, closeTo(0.3, 0.0001)); + expect(reloaded.settings.isEventEnabled(SoundEvent.send), isTrue); + }); + + test('player failures never propagate', () async { + final service = SoundService(player: _ThrowingPlayer()); + await service.playEvent(SoundEvent.mention); + await service.previewEvent(SoundEvent.mention); + }); + }); + + group('SoundSettingsScreen', () { + testWidgets('toggles events and previews sounds', (tester) async { + final player = _RecordingPlayer(); + final service = SoundService(player: player); + + await tester.pumpWidget( + MaterialApp(home: SoundSettingsScreen(service: service)), + ); + + await tester.tap(find.byKey(const Key('sound-settings-preview-mention'))); + await tester.pump(); + expect(player.played, hasLength(1)); + + // join defaults to off; enable it via the switch. + await tester.scrollUntilVisible( + find.byKey(const Key('sound-settings-event-join')), + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap(find.byKey(const Key('sound-settings-event-join'))); + await tester.pumpAndSettle(); + expect(service.settings.isEventEnabled(SoundEvent.join), isTrue); + + // Global switch hides the per-event list. + await tester.scrollUntilVisible( + find.byKey(const Key('sound-settings-enabled')), + -200, + scrollable: find.byType(Scrollable).first, + ); + await tester.tap(find.byKey(const Key('sound-settings-enabled'))); + await tester.pumpAndSettle(); + expect(service.settings.enabled, isFalse); + expect(find.byKey(const Key('sound-settings-event-join')), findsNothing); + }); + }); +} diff --git a/test/storage_repositories_test.dart b/test/storage_repositories_test.dart index 9d5537a..77a1dc8 100644 --- a/test/storage_repositories_test.dart +++ b/test/storage_repositories_test.dart @@ -630,9 +630,33 @@ void main() { expect(settings.messageFontScale, 1.2); expect(settings.messageDensity, MessageDensity.compact); expect(settings.monospaceMessages, isTrue); + expect(settings.messageFontFamily, 'system'); expect(settings.nickColorMode, NickColorMode.vivid); }); + test('legacy monospace toggle migrates into the message font family', () { + // Stored settings from before messageFontFamily existed. + final settings = AppSettings.fromJson({ + 'monospaceMessages': true, + }); + expect(settings.messageFontFamily, 'monospace'); + }); + + test( + 'settings repository saves and loads the message font family', + () async { + final repository = SharedPrefsSettingsRepository(); + + await repository.saveSettings( + const AppSettings(messageFontFamily: 'serif'), + ); + final settings = await repository.loadSettings(); + + expect(settings.messageFontFamily, 'serif'); + expect(settings.monospaceMessages, isFalse); + }, + ); + test('chat session persistence saves tabs and history', () async { final persistence = ChatSessionPersistence(); const tab = ChatTab( diff --git a/test/widget_test.dart b/test/widget_test.dart index 9dfb7f3..26b8c82 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -1,8 +1,8 @@ import 'dart:async'; -import 'dart:typed_data'; import 'package:androidircx/app/app.dart'; import 'package:androidircx/core/models/app_settings.dart'; +import 'package:androidircx/core/models/dcc_session.dart'; import 'package:androidircx/core/models/network_config.dart'; import 'package:androidircx/core/platform/foreground_connection_service.dart'; import 'package:androidircx/core/presets/server_preset_service.dart'; @@ -32,6 +32,7 @@ import 'package:androidircx/irc/services/irc_service.dart'; import 'package:androidircx/irc/services/irc_transport.dart'; import 'package:androidircx/media/services/media_download_service.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -730,11 +731,13 @@ void main() { await tester.tap(find.text('Compact').last); await tester.pumpAndSettle(); - await scrollTo('settings-monospace-messages'); + await scrollTo('settings-message-font-family'); await tester.tap( - find.byKey(const Key('settings-monospace-messages')).first, + find.byKey(const Key('settings-message-font-family')).first, ); await tester.pumpAndSettle(); + await tester.tap(find.text('Monospace').last); + await tester.pumpAndSettle(); await scrollTo('settings-nick-color-mode'); await tester.tap(find.byKey(const Key('settings-nick-color-mode')).first); @@ -746,6 +749,7 @@ void main() { expect(settings.themePreset, AppThemePreset.custom); expect(settings.customThemeJson, customJson); expect(settings.messageDensity, MessageDensity.compact); + expect(settings.messageFontFamily, 'monospace'); expect(settings.monospaceMessages, isTrue); expect(settings.nickColorMode, NickColorMode.vivid); }); @@ -1470,12 +1474,28 @@ void main() { await tester.pump(); await tester.pump(); + // Reacting from the actions sheet sends a TAGMSG with the combined tag. await tester.longPress(find.textContaining('Delete this').first); await tester.pumpAndSettle(); - expect(find.text('Delete message'), findsOneWidget); + await tester.tap(find.byKey(const Key('message-react-👍'))); + await tester.pumpAndSettle(); + expect( + transport.sentLines, + contains('@+draft/react=seed-redact\\:👍 TAGMSG #room'), + ); - await tester.ensureVisible(find.text('Delete message')); + await tester.longPress(find.textContaining('Delete this').first); await tester.pumpAndSettle(); + + // The quick-reaction row sits above the actions and can push this item + // below the fold in the sheet's list. + await tester.scrollUntilVisible( + find.text('Delete message'), + 120, + scrollable: find.byType(Scrollable).last, + ); + expect(find.text('Delete message'), findsOneWidget); + await tester.tap(find.text('Delete message')); await tester.pump(); @@ -1566,6 +1586,104 @@ void main() { controller.dispose(); }); + testWidgets('switches tabs with hardware keyboard shortcuts', (tester) async { + SharedPreferences.setMockInitialValues({}); + const network = NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.dbase.in.rs', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + ); + final transport = _FakeTransport(); + final controller = ChatSessionController( + network: network, + ircService: IrcService(transportConnector: (_) async => transport), + ); + + await tester.pumpWidget( + MaterialApp(home: ChatScreen(controller: controller)), + ); + await tester.pump(); + await controller.joinChannel(const JoinChannelRequest(channel: '#room')); + await tester.pump(); + + // Joining focuses the channel tab; the server tab is the other one. + final startTab = controller.activeTab.name; + expect(startTab, '#room'); + + // Focus the composer so key events land inside the shortcut scope. + await tester.tap(find.byType(TextField).first); + await tester.pump(); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await tester.pump(); + + expect(controller.activeTab.name, isNot('#room')); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + await tester.sendKeyUpEvent(LogicalKeyboardKey.altLeft); + await tester.pump(); + + expect(controller.activeTab.name, '#room'); + + controller.dispose(); + }); + + testWidgets('lists dcc sessions in the transfers modal', (tester) async { + SharedPreferences.setMockInitialValues({}); + const network = NetworkConfig( + id: 'dbase', + name: 'DBase', + host: 'irc.dbase.in.rs', + port: 6697, + nickname: 'AndroidIRCX', + altNickname: 'AndroidIRCX_', + ); + final transport = _FakeTransport(); + final controller = ChatSessionController( + network: network, + ircService: IrcService(transportConnector: (_) async => transport), + ); + + await tester.pumpWidget( + MaterialApp(home: ChatScreen(controller: controller)), + ); + await tester.pump(); + + // No sessions yet -> no transfers button in the app bar. + expect(find.byKey(const Key('chat-dcc-transfers')), findsNothing); + + transport.emit( + ':alice!user@example PRIVMSG AndroidIRCX :DCC SEND notes.txt 2130706433 5002 2048', + ); + await tester.pump(); + await tester.pump(); + + expect(find.byKey(const Key('chat-dcc-transfers')), findsOneWidget); + + await tester.tap(find.byKey(const Key('chat-dcc-transfers'))); + await tester.pumpAndSettle(); + + expect(find.text('DCC transfers'), findsOneWidget); + expect(find.textContaining('notes.txt'), findsWidgets); + + final session = controller.dccSessions.single; + await tester.tap(find.byKey(Key('dcc-transfer-close-${session.tabId}'))); + await tester.pumpAndSettle(); + + expect( + controller.dccSessions.single.status, + anyOf(DccSessionStatus.closed, DccSessionStatus.failed), + ); + + controller.dispose(); + }); + testWidgets('shows reverse dcc limitations for passive send offers', ( tester, ) async { diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 30d4ad0..f54e12f 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -15,6 +16,8 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { + AudioplayersWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); FirebaseAppCheckPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 030c229..a3e1dfc 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_windows file_selector_windows firebase_app_check firebase_core