Skip to content

Commit c1870c8

Browse files
committed
Advance sprint 4 auth and multi-session parity
Add shared session orchestration, auto-connect startup, SCRAM-SHA-256 support, richer auth status handling, and broader widget and service test coverage for the current Sprint 4 scope.
1 parent cf6228e commit c1870c8

18 files changed

Lines changed: 966 additions & 33 deletions

lib/core/models/network_config.dart

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
enum SaslMechanism {
2+
plain,
3+
scramSha256,
4+
}
5+
16
class NetworkConfig {
27
const NetworkConfig({
38
required this.id,
@@ -12,6 +17,7 @@ class NetworkConfig {
1217
this.password,
1318
this.saslAccount,
1419
this.saslPassword,
20+
this.saslMechanism = SaslMechanism.plain,
1521
this.autoConnect = false,
1622
});
1723

@@ -27,6 +33,7 @@ class NetworkConfig {
2733
final String? password;
2834
final String? saslAccount;
2935
final String? saslPassword;
36+
final SaslMechanism saslMechanism;
3037
final bool autoConnect;
3138

3239
NetworkConfig copyWith({
@@ -42,6 +49,7 @@ class NetworkConfig {
4249
String? password,
4350
String? saslAccount,
4451
String? saslPassword,
52+
SaslMechanism? saslMechanism,
4553
bool? autoConnect,
4654
}) {
4755
return NetworkConfig(
@@ -57,6 +65,7 @@ class NetworkConfig {
5765
password: password ?? this.password,
5866
saslAccount: saslAccount ?? this.saslAccount,
5967
saslPassword: saslPassword ?? this.saslPassword,
68+
saslMechanism: saslMechanism ?? this.saslMechanism,
6069
autoConnect: autoConnect ?? this.autoConnect,
6170
);
6271
}
@@ -75,6 +84,7 @@ class NetworkConfig {
7584
'password': password,
7685
'saslAccount': saslAccount,
7786
'saslPassword': saslPassword,
87+
'saslMechanism': saslMechanism.name,
7888
'autoConnect': autoConnect,
7989
};
8090
}
@@ -93,6 +103,9 @@ class NetworkConfig {
93103
password: json['password'] as String?,
94104
saslAccount: json['saslAccount'] as String?,
95105
saslPassword: json['saslPassword'] as String?,
106+
saslMechanism: json['saslMechanism'] == null
107+
? SaslMechanism.plain
108+
: SaslMechanism.values.byName(json['saslMechanism']! as String),
96109
autoConnect: (json['autoConnect'] as bool?) ?? false,
97110
);
98111
}

lib/features/bootstrap/presentation/bootstrap_screen.dart

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import 'package:androidircx/core/storage/shared_prefs_network_repository.dart';
2+
import 'package:androidircx/features/chat/application/session_registry.dart';
23
import 'package:androidircx/features/connections/application/network_list_controller.dart';
34
import 'package:androidircx/features/connections/presentation/network_list_screen.dart';
45
import 'package:flutter/material.dart';
@@ -12,23 +13,57 @@ class BootstrapScreen extends StatefulWidget {
1213

1314
class _BootstrapScreenState extends State<BootstrapScreen> {
1415
late final NetworkListController _controller;
16+
late final SessionRegistry _sessionRegistry;
17+
bool _bootstrapComplete = false;
1518

1619
@override
1720
void initState() {
1821
super.initState();
22+
_sessionRegistry = SessionRegistry();
1923
_controller = NetworkListController(
2024
repository: SharedPrefsNetworkRepository(),
21-
)..load();
25+
);
26+
_bootstrap();
27+
}
28+
29+
Future<void> _bootstrap() async {
30+
await _controller.load();
31+
for (final network in _controller.networks.where((item) => item.autoConnect)) {
32+
final session = _sessionRegistry.obtainSession(network);
33+
await session.start();
34+
}
35+
36+
if (!mounted) {
37+
return;
38+
}
39+
40+
setState(() {
41+
_bootstrapComplete = true;
42+
});
2243
}
2344

2445
@override
2546
void dispose() {
2647
_controller.dispose();
48+
_sessionRegistry.dispose();
2749
super.dispose();
2850
}
2951

3052
@override
3153
Widget build(BuildContext context) {
32-
return NetworkListScreen(controller: _controller);
54+
if (!_bootstrapComplete && _controller.isLoading) {
55+
return const Scaffold(
56+
body: SafeArea(
57+
child: Center(
58+
child: CircularProgressIndicator(),
59+
),
60+
),
61+
);
62+
}
63+
64+
return NetworkListScreen(
65+
controller: _controller,
66+
sessionRegistry: _sessionRegistry,
67+
);
3368
}
3469
}

lib/features/chat/application/chat_session_controller.dart

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ class ChatSessionController extends ChangeNotifier {
6969
bool get isReconnectScheduled => _reconnectTimer?.isActive ?? false;
7070
Duration? get pendingReconnectDelay => _pendingReconnectDelay;
7171
ChatTab get activeTab => _tabs.firstWhere((tab) => tab.id == _activeTabId);
72+
String get currentNick => _ircService.currentNick ?? network.nickname;
7273
String? get activeChannelTopic => _channelTopics[activeTabId];
7374
String? get activeChannelModes => _channelModes[activeTabId];
7475
String get activeChannelSummary {
@@ -478,6 +479,20 @@ class ChatSessionController extends ChangeNotifier {
478479
frame,
479480
'WHOIS: ${frame.params.length > 3 ? '${frame.params[1]} is ${frame.params[2]}@${frame.params[3]}' : frame.raw}',
480481
);
482+
case '900':
483+
case '901':
484+
case '902':
485+
case '903':
486+
case '904':
487+
case '905':
488+
case '906':
489+
case '907':
490+
_appendMessage(
491+
tabId: _serverTabId(network.id),
492+
sender: 'auth',
493+
content: frame.trailing ?? frame.raw,
494+
kind: IrcMessageKind.system,
495+
);
481496
case '312':
482497
_appendWhoisMessage(
483498
frame,
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import 'package:androidircx/core/models/connection_state.dart';
2+
import 'package:androidircx/core/models/network_config.dart';
3+
import 'package:androidircx/features/chat/application/chat_session_controller.dart';
4+
import 'package:flutter/foundation.dart';
5+
6+
class SessionRegistry extends ChangeNotifier {
7+
final Map<String, ChatSessionController> _sessions = {};
8+
final Map<String, VoidCallback> _listeners = {};
9+
10+
List<ChatSessionController> get sessions =>
11+
List<ChatSessionController>.unmodifiable(_sessions.values);
12+
13+
bool hasSession(String networkId) => _sessions.containsKey(networkId);
14+
15+
ChatSessionController obtainSession(NetworkConfig network) {
16+
final existing = _sessions[network.id];
17+
if (existing != null) {
18+
return existing;
19+
}
20+
21+
final controller = ChatSessionController(network: network);
22+
void listener() => notifyListeners();
23+
controller.addListener(listener);
24+
_listeners[network.id] = listener;
25+
_sessions[network.id] = controller;
26+
notifyListeners();
27+
return controller;
28+
}
29+
30+
ConnectionSnapshot connectionFor(String networkId) {
31+
return _sessions[networkId]?.connection ??
32+
const ConnectionSnapshot(networkId: '', phase: ConnectionPhase.idle);
33+
}
34+
35+
String? currentNickFor(String networkId) {
36+
return _sessions[networkId]?.currentNick;
37+
}
38+
39+
Future<void> closeSession(String networkId) async {
40+
final controller = _sessions.remove(networkId);
41+
final listener = _listeners.remove(networkId);
42+
if (controller == null) {
43+
return;
44+
}
45+
46+
if (listener != null) {
47+
controller.removeListener(listener);
48+
}
49+
await controller.disconnect();
50+
controller.dispose();
51+
notifyListeners();
52+
}
53+
54+
@override
55+
void dispose() {
56+
for (final entry in _sessions.entries) {
57+
final listener = _listeners[entry.key];
58+
if (listener != null) {
59+
entry.value.removeListener(listener);
60+
}
61+
entry.value.dispose();
62+
}
63+
_sessions.clear();
64+
_listeners.clear();
65+
super.dispose();
66+
}
67+
}

lib/features/chat/presentation/chat_screen.dart

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,30 +11,29 @@ import 'package:flutter/material.dart';
1111
class ChatScreen extends StatefulWidget {
1212
const ChatScreen({
1313
super.key,
14-
required this.network,
14+
required this.controller,
1515
});
1616

17-
final NetworkConfig network;
17+
final ChatSessionController controller;
1818

1919
@override
2020
State<ChatScreen> createState() => _ChatScreenState();
2121
}
2222

2323
class _ChatScreenState extends State<ChatScreen> {
24-
late final ChatSessionController _controller;
2524
final TextEditingController _composerController = TextEditingController();
2625

26+
ChatSessionController get _controller => widget.controller;
27+
2728
@override
2829
void initState() {
2930
super.initState();
30-
_controller = ChatSessionController(network: widget.network);
3131
_controller.start();
3232
}
3333

3434
@override
3535
void dispose() {
3636
_composerController.dispose();
37-
_controller.dispose();
3837
super.dispose();
3938
}
4039

@@ -99,8 +98,9 @@ class _ChatScreenState extends State<ChatScreen> {
9998
child: Column(
10099
children: [
101100
ListTile(
102-
title: Text(widget.network.name),
103-
subtitle: Text('${widget.network.host}:${widget.network.port}'),
101+
title: Text(_controller.network.name),
102+
subtitle:
103+
Text('${_controller.network.host}:${_controller.network.port}'),
104104
),
105105
const Divider(height: 1),
106106
Expanded(
@@ -188,7 +188,10 @@ class _ChatScreenState extends State<ChatScreen> {
188188
body: SafeArea(
189189
child: Column(
190190
children: [
191-
_ConnectionBanner(controller: _controller, network: widget.network),
191+
_ConnectionBanner(
192+
controller: _controller,
193+
network: _controller.network,
194+
),
192195
if ((_controller.activeChannelTopic ?? '').trim().isNotEmpty)
193196
_ChannelTopicBar(topic: _controller.activeChannelTopic!.trim()),
194197
Expanded(
@@ -464,6 +467,11 @@ class _ConnectionBanner extends StatelessWidget {
464467
'${network.host}:${network.port} • ${network.useTls ? 'TLS' : 'Plain TCP'}',
465468
style: theme.textTheme.bodySmall,
466469
),
470+
const SizedBox(height: 4),
471+
Text(
472+
'Current nick: ${controller.currentNick}',
473+
style: theme.textTheme.bodySmall,
474+
),
467475
if ((snapshot.message ?? '').isNotEmpty) ...[
468476
const SizedBox(height: 4),
469477
Text(snapshot.message!, style: theme.textTheme.bodySmall),

lib/features/connections/application/network_list_controller.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ class NetworkListController extends ChangeNotifier {
3232
required String nickname,
3333
required String altNickname,
3434
required bool useTls,
35+
required bool autoConnect,
36+
required SaslMechanism saslMechanism,
3537
String? saslAccount,
3638
String? saslPassword,
3739
String? networkId,
@@ -44,6 +46,8 @@ class NetworkListController extends ChangeNotifier {
4446
nickname: nickname,
4547
altNickname: altNickname.trim(),
4648
useTls: useTls,
49+
autoConnect: autoConnect,
50+
saslMechanism: saslMechanism,
4751
saslAccount: (saslAccount ?? '').trim().isEmpty ? null : saslAccount?.trim(),
4852
saslPassword: (saslPassword ?? '').trim().isEmpty ? null : saslPassword,
4953
);

lib/features/connections/presentation/network_form_screen.dart

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ class NetworkFormResult {
99
required this.nickname,
1010
required this.altNickname,
1111
required this.useTls,
12+
required this.autoConnect,
13+
required this.saslMechanism,
1214
this.saslAccount,
1315
this.saslPassword,
1416
});
@@ -19,6 +21,8 @@ class NetworkFormResult {
1921
final String nickname;
2022
final String altNickname;
2123
final bool useTls;
24+
final bool autoConnect;
25+
final SaslMechanism saslMechanism;
2226
final String? saslAccount;
2327
final String? saslPassword;
2428
}
@@ -45,6 +49,8 @@ class _NetworkFormScreenState extends State<NetworkFormScreen> {
4549
late final TextEditingController _saslAccountController;
4650
late final TextEditingController _saslPasswordController;
4751
late bool _useTls;
52+
late bool _autoConnect;
53+
late SaslMechanism _saslMechanism;
4854

4955
@override
5056
void initState() {
@@ -64,6 +70,8 @@ class _NetworkFormScreenState extends State<NetworkFormScreen> {
6470
_saslAccountController = TextEditingController(text: initial?.saslAccount ?? '');
6571
_saslPasswordController = TextEditingController(text: initial?.saslPassword ?? '');
6672
_useTls = initial?.useTls ?? true;
73+
_autoConnect = initial?.autoConnect ?? false;
74+
_saslMechanism = initial?.saslMechanism ?? SaslMechanism.plain;
6775
}
6876

6977
@override
@@ -150,6 +158,29 @@ class _NetworkFormScreenState extends State<NetworkFormScreen> {
150158
labelText: 'SASL password',
151159
),
152160
),
161+
const SizedBox(height: 16),
162+
DropdownButtonFormField<SaslMechanism>(
163+
initialValue: _saslMechanism,
164+
decoration: const InputDecoration(
165+
labelText: 'SASL mechanism',
166+
),
167+
items: const [
168+
DropdownMenuItem<SaslMechanism>(
169+
value: SaslMechanism.plain,
170+
child: Text('PLAIN'),
171+
),
172+
DropdownMenuItem<SaslMechanism>(
173+
value: SaslMechanism.scramSha256,
174+
child: Text('SCRAM-SHA-256'),
175+
),
176+
],
177+
onChanged: (value) {
178+
if (value == null) {
179+
return;
180+
}
181+
setState(() => _saslMechanism = value);
182+
},
183+
),
153184
const SizedBox(height: 12),
154185
SwitchListTile(
155186
contentPadding: EdgeInsets.zero,
@@ -158,6 +189,13 @@ class _NetworkFormScreenState extends State<NetworkFormScreen> {
158189
value: _useTls,
159190
onChanged: (value) => setState(() => _useTls = value),
160191
),
192+
SwitchListTile(
193+
contentPadding: EdgeInsets.zero,
194+
title: const Text('Auto connect'),
195+
subtitle: const Text('Start this network automatically on app launch.'),
196+
value: _autoConnect,
197+
onChanged: (value) => setState(() => _autoConnect = value),
198+
),
161199
const SizedBox(height: 24),
162200
FilledButton(
163201
onPressed: _submit,
@@ -192,6 +230,8 @@ class _NetworkFormScreenState extends State<NetworkFormScreen> {
192230
nickname: _nicknameController.text.trim(),
193231
altNickname: _altNicknameController.text.trim(),
194232
useTls: _useTls,
233+
autoConnect: _autoConnect,
234+
saslMechanism: _saslMechanism,
195235
saslAccount: _saslAccountController.text.trim(),
196236
saslPassword: _saslPasswordController.text,
197237
),

0 commit comments

Comments
 (0)