Merge pull request #116 from cogwheel0/transport-mode-selection-localization
feat(transport): Improve socket transport mode selection and localization
This commit is contained in:
53
lib/core/models/backend_config.dart
Normal file
53
lib/core/models/backend_config.dart
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// Subset of the backend `/api/config` response the app cares about.
|
||||||
|
@immutable
|
||||||
|
class BackendConfig {
|
||||||
|
const BackendConfig({this.enableWebsocket});
|
||||||
|
|
||||||
|
/// Mirrors `features.enable_websocket` from OpenWebUI.
|
||||||
|
final bool? enableWebsocket;
|
||||||
|
|
||||||
|
/// Returns a copy with updated fields.
|
||||||
|
BackendConfig copyWith({bool? enableWebsocket}) {
|
||||||
|
return BackendConfig(
|
||||||
|
enableWebsocket: enableWebsocket ?? this.enableWebsocket,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the backend only allows WebSocket transport.
|
||||||
|
bool get websocketOnly => enableWebsocket == true;
|
||||||
|
|
||||||
|
/// Whether the backend only allows HTTP polling transport.
|
||||||
|
bool get pollingOnly => enableWebsocket == false;
|
||||||
|
|
||||||
|
/// Whether the backend permits choosing WebSocket-only mode.
|
||||||
|
bool get supportsWebsocketOnly => !pollingOnly;
|
||||||
|
|
||||||
|
/// Whether the backend permits choosing polling fallback.
|
||||||
|
bool get supportsPolling => !websocketOnly;
|
||||||
|
|
||||||
|
/// Returns the enforced transport mode derived from backend policy.
|
||||||
|
String? get enforcedTransportMode {
|
||||||
|
if (websocketOnly) return 'ws';
|
||||||
|
if (pollingOnly) return 'polling';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() {
|
||||||
|
return <String, dynamic>{'enable_websocket': enableWebsocket};
|
||||||
|
}
|
||||||
|
|
||||||
|
static BackendConfig fromJson(Map<String, dynamic> json) {
|
||||||
|
bool? enableWebsocket;
|
||||||
|
final features = json['features'];
|
||||||
|
if (features is Map<String, dynamic>) {
|
||||||
|
final value = features['enable_websocket'];
|
||||||
|
if (value is bool) {
|
||||||
|
enableWebsocket = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return BackendConfig(enableWebsocket: enableWebsocket);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import '../models/user.dart';
|
|||||||
import '../models/model.dart';
|
import '../models/model.dart';
|
||||||
import '../models/conversation.dart';
|
import '../models/conversation.dart';
|
||||||
import '../models/chat_message.dart';
|
import '../models/chat_message.dart';
|
||||||
|
import '../models/backend_config.dart';
|
||||||
import '../models/folder.dart';
|
import '../models/folder.dart';
|
||||||
import '../models/user_settings.dart';
|
import '../models/user_settings.dart';
|
||||||
import '../models/file_info.dart';
|
import '../models/file_info.dart';
|
||||||
@@ -172,6 +173,84 @@ final serverConnectionStateProvider = Provider<bool>((ref) {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
final backendConfigProvider = FutureProvider<BackendConfig?>((ref) async {
|
||||||
|
final api = ref.watch(apiServiceProvider);
|
||||||
|
if (api == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final server = await ref.watch(activeServerProvider.future);
|
||||||
|
if (server == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final config = await api.getBackendConfig();
|
||||||
|
if (config != null) {
|
||||||
|
final forcedMode = config.enforcedTransportMode;
|
||||||
|
if (forcedMode != null) {
|
||||||
|
final settings = ref.read(appSettingsProvider);
|
||||||
|
if (settings.socketTransportMode != forcedMode) {
|
||||||
|
Future.microtask(() {
|
||||||
|
ref
|
||||||
|
.read(appSettingsProvider.notifier)
|
||||||
|
.setSocketTransportMode(forcedMode);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
class SocketTransportAvailability {
|
||||||
|
const SocketTransportAvailability({
|
||||||
|
required this.allowPolling,
|
||||||
|
required this.allowWebsocketOnly,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool allowPolling;
|
||||||
|
final bool allowWebsocketOnly;
|
||||||
|
}
|
||||||
|
|
||||||
|
final socketTransportOptionsProvider = Provider<SocketTransportAvailability>((
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
final backendConfigAsync = ref.watch(backendConfigProvider);
|
||||||
|
final config = backendConfigAsync.maybeWhen(
|
||||||
|
data: (value) => value,
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (config == null) {
|
||||||
|
return const SocketTransportAvailability(
|
||||||
|
allowPolling: true,
|
||||||
|
allowWebsocketOnly: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.websocketOnly) {
|
||||||
|
return const SocketTransportAvailability(
|
||||||
|
allowPolling: false,
|
||||||
|
allowWebsocketOnly: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.pollingOnly) {
|
||||||
|
return const SocketTransportAvailability(
|
||||||
|
allowPolling: true,
|
||||||
|
allowWebsocketOnly: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const SocketTransportAvailability(
|
||||||
|
allowPolling: true,
|
||||||
|
allowWebsocketOnly: true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// API Service provider with unified auth integration
|
// API Service provider with unified auth integration
|
||||||
final apiServiceProvider = Provider<ApiService?>((ref) {
|
final apiServiceProvider = Provider<ApiService?>((ref) {
|
||||||
// If reviewer mode is enabled, skip creating ApiService
|
// If reviewer mode is enabled, skip creating ApiService
|
||||||
@@ -242,6 +321,8 @@ class SocketServiceManager extends _$SocketServiceManager {
|
|||||||
appSettingsProvider.select((settings) => settings.socketTransportMode),
|
appSettingsProvider.select((settings) => settings.socketTransportMode),
|
||||||
);
|
);
|
||||||
final websocketOnly = transportMode == 'ws';
|
final websocketOnly = transportMode == 'ws';
|
||||||
|
final transportAvailability = ref.watch(socketTransportOptionsProvider);
|
||||||
|
final allowWebsocketUpgrade = transportAvailability.allowWebsocketOnly;
|
||||||
|
|
||||||
// Don't watch authTokenProvider3 here to avoid rebuilding on token changes
|
// Don't watch authTokenProvider3 here to avoid rebuilding on token changes
|
||||||
// Token updates are handled via the subscription below
|
// Token updates are handled via the subscription below
|
||||||
@@ -250,13 +331,15 @@ class SocketServiceManager extends _$SocketServiceManager {
|
|||||||
final requiresNewService =
|
final requiresNewService =
|
||||||
_service == null ||
|
_service == null ||
|
||||||
_service!.serverConfig.id != server.id ||
|
_service!.serverConfig.id != server.id ||
|
||||||
_service!.websocketOnly != websocketOnly;
|
_service!.websocketOnly != websocketOnly ||
|
||||||
|
_service!.allowWebsocketUpgrade != allowWebsocketUpgrade;
|
||||||
if (requiresNewService) {
|
if (requiresNewService) {
|
||||||
_disposeService();
|
_disposeService();
|
||||||
_service = SocketService(
|
_service = SocketService(
|
||||||
serverConfig: server,
|
serverConfig: server,
|
||||||
authToken: token,
|
authToken: token,
|
||||||
websocketOnly: websocketOnly,
|
websocketOnly: websocketOnly,
|
||||||
|
allowWebsocketUpgrade: allowWebsocketUpgrade,
|
||||||
);
|
);
|
||||||
_scheduleConnect(_service!);
|
_scheduleConnect(_service!);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart';
|
|||||||
// import 'package:http_parser/http_parser.dart';
|
// import 'package:http_parser/http_parser.dart';
|
||||||
// Removed legacy websocket/socket.io imports
|
// Removed legacy websocket/socket.io imports
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
import '../models/backend_config.dart';
|
||||||
import '../models/server_config.dart';
|
import '../models/server_config.dart';
|
||||||
import '../models/user.dart';
|
import '../models/user.dart';
|
||||||
import '../models/model.dart';
|
import '../models/model.dart';
|
||||||
@@ -253,6 +254,44 @@ class ApiService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<BackendConfig?> getBackendConfig() async {
|
||||||
|
try {
|
||||||
|
final response = await _dio.get('/api/config');
|
||||||
|
final data = response.data;
|
||||||
|
Map<String, dynamic>? jsonMap;
|
||||||
|
if (data is Map<String, dynamic>) {
|
||||||
|
jsonMap = data;
|
||||||
|
} else if (data is String && data.isNotEmpty) {
|
||||||
|
final decoded = json.decode(data);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
jsonMap = decoded;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (jsonMap == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return BackendConfig.fromJson(jsonMap);
|
||||||
|
} on DioException catch (e, stackTrace) {
|
||||||
|
_traceApi('Backend config request failed: $e');
|
||||||
|
DebugLogger.error(
|
||||||
|
'backend-config-error',
|
||||||
|
scope: 'api/config',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
rethrow;
|
||||||
|
} catch (e, stackTrace) {
|
||||||
|
_traceApi('Backend config decode error: $e');
|
||||||
|
DebugLogger.error(
|
||||||
|
'backend-config-decode',
|
||||||
|
scope: 'api/config',
|
||||||
|
error: e,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Authentication
|
// Authentication
|
||||||
Future<Map<String, dynamic>> login(String username, String password) async {
|
Future<Map<String, dynamic>> login(String username, String password) async {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class SettingsService {
|
|||||||
static const String _voiceAutoSendKey = PreferenceKeys.voiceAutoSendFinal;
|
static const String _voiceAutoSendKey = PreferenceKeys.voiceAutoSendFinal;
|
||||||
// Realtime transport preference
|
// Realtime transport preference
|
||||||
static const String _socketTransportModeKey =
|
static const String _socketTransportModeKey =
|
||||||
PreferenceKeys.socketTransportMode; // 'auto' or 'ws'
|
PreferenceKeys.socketTransportMode; // 'polling' or 'ws'
|
||||||
// Quick pill visibility selections (max 2)
|
// Quick pill visibility selections (max 2)
|
||||||
static const String _quickPillsKey = PreferenceKeys
|
static const String _quickPillsKey = PreferenceKeys
|
||||||
.quickPills; // StringList of identifiers e.g. ['web','image','tools']
|
.quickPills; // StringList of identifiers e.g. ['web','image','tools']
|
||||||
@@ -256,15 +256,27 @@ class SettingsService {
|
|||||||
return _preferencesBox().put(_voiceAutoSendKey, value);
|
return _preferencesBox().put(_voiceAutoSendKey, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Transport mode: 'auto' (polling+websocket) or 'ws' (websocket only)
|
/// Transport mode: 'polling' (HTTP polling + WebSocket upgrade) or 'ws'
|
||||||
static Future<String> getSocketTransportMode() {
|
static Future<String> getSocketTransportMode() {
|
||||||
final value = _preferencesBox().get(_socketTransportModeKey) as String?;
|
final raw = _preferencesBox().get(_socketTransportModeKey) as String?;
|
||||||
return Future.value(value ?? 'ws');
|
if (raw == null) {
|
||||||
|
return Future.value('ws');
|
||||||
|
}
|
||||||
|
if (raw == 'auto') {
|
||||||
|
return Future.value('polling');
|
||||||
|
}
|
||||||
|
if (raw != 'polling' && raw != 'ws') {
|
||||||
|
return Future.value('ws');
|
||||||
|
}
|
||||||
|
return Future.value(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> setSocketTransportMode(String mode) {
|
static Future<void> setSocketTransportMode(String mode) {
|
||||||
if (mode != 'auto' && mode != 'ws') {
|
if (mode == 'auto') {
|
||||||
mode = 'auto';
|
mode = 'polling';
|
||||||
|
}
|
||||||
|
if (mode != 'polling' && mode != 'ws') {
|
||||||
|
mode = 'polling';
|
||||||
}
|
}
|
||||||
return _preferencesBox().put(_socketTransportModeKey, mode);
|
return _preferencesBox().put(_socketTransportModeKey, mode);
|
||||||
}
|
}
|
||||||
@@ -344,7 +356,7 @@ class AppSettings {
|
|||||||
final String? voiceLocaleId;
|
final String? voiceLocaleId;
|
||||||
final bool voiceHoldToTalk;
|
final bool voiceHoldToTalk;
|
||||||
final bool voiceAutoSendFinal;
|
final bool voiceAutoSendFinal;
|
||||||
final String socketTransportMode; // 'auto' or 'ws'
|
final String socketTransportMode; // 'polling' or 'ws'
|
||||||
final List<String> quickPills; // e.g., ['web','image']
|
final List<String> quickPills; // e.g., ['web','image']
|
||||||
final bool sendOnEnter;
|
final bool sendOnEnter;
|
||||||
final String? ttsVoice;
|
final String? ttsVoice;
|
||||||
@@ -566,8 +578,17 @@ class AppSettingsNotifier extends _$AppSettingsNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setSocketTransportMode(String mode) async {
|
Future<void> setSocketTransportMode(String mode) async {
|
||||||
state = state.copyWith(socketTransportMode: mode);
|
var sanitized = mode;
|
||||||
await SettingsService.setSocketTransportMode(mode);
|
if (sanitized == 'auto') {
|
||||||
|
sanitized = 'polling';
|
||||||
|
}
|
||||||
|
if (sanitized != 'polling' && sanitized != 'ws') {
|
||||||
|
sanitized = 'polling';
|
||||||
|
}
|
||||||
|
if (state.socketTransportMode != sanitized) {
|
||||||
|
state = state.copyWith(socketTransportMode: sanitized);
|
||||||
|
}
|
||||||
|
await SettingsService.setSocketTransportMode(sanitized);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> setQuickPills(List<String> pills) async {
|
Future<void> setQuickPills(List<String> pills) async {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ typedef SocketChannelEventHandler =
|
|||||||
class SocketService with WidgetsBindingObserver {
|
class SocketService with WidgetsBindingObserver {
|
||||||
final ServerConfig serverConfig;
|
final ServerConfig serverConfig;
|
||||||
final bool websocketOnly;
|
final bool websocketOnly;
|
||||||
|
final bool allowWebsocketUpgrade;
|
||||||
io.Socket? _socket;
|
io.Socket? _socket;
|
||||||
String? _authToken;
|
String? _authToken;
|
||||||
bool _isAppForeground = true;
|
bool _isAppForeground = true;
|
||||||
@@ -34,6 +35,7 @@ class SocketService with WidgetsBindingObserver {
|
|||||||
required this.serverConfig,
|
required this.serverConfig,
|
||||||
String? authToken,
|
String? authToken,
|
||||||
this.websocketOnly = false,
|
this.websocketOnly = false,
|
||||||
|
this.allowWebsocketUpgrade = true,
|
||||||
}) : _authToken = authToken {
|
}) : _authToken = authToken {
|
||||||
final binding = WidgetsBinding.instance;
|
final binding = WidgetsBinding.instance;
|
||||||
final lifecycle = binding.lifecycleState;
|
final lifecycle = binding.lifecycleState;
|
||||||
@@ -72,11 +74,18 @@ class SocketService with WidgetsBindingObserver {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
final path = '/ws/socket.io';
|
final path = '/ws/socket.io';
|
||||||
|
|
||||||
|
final usePollingOnly = !websocketOnly && !allowWebsocketUpgrade;
|
||||||
|
final transports = websocketOnly
|
||||||
|
? const ['websocket']
|
||||||
|
: usePollingOnly
|
||||||
|
? const ['polling']
|
||||||
|
: const ['polling', 'websocket'];
|
||||||
|
|
||||||
final builder = io.OptionBuilder()
|
final builder = io.OptionBuilder()
|
||||||
// Transport selection - WebSocket only, no polling fallback
|
// Transport selection switches between WebSocket-only and polling fallback
|
||||||
.setTransports(['websocket'])
|
.setTransports(transports)
|
||||||
.setRememberUpgrade(false)
|
.setRememberUpgrade(!websocketOnly && allowWebsocketUpgrade)
|
||||||
.setUpgrade(false)
|
.setUpgrade(!websocketOnly && allowWebsocketUpgrade)
|
||||||
// Tune reconnect/backoff and timeouts
|
// Tune reconnect/backoff and timeouts
|
||||||
.setReconnectionAttempts(0) // 0/Infinity semantics: unlimited attempts
|
.setReconnectionAttempts(0) // 0/Infinity semantics: unlimited attempts
|
||||||
.setReconnectionDelay(1000)
|
.setReconnectionDelay(1000)
|
||||||
|
|||||||
@@ -392,6 +392,18 @@ class AppCustomizationPage extends ConsumerWidget {
|
|||||||
) {
|
) {
|
||||||
final theme = context.conduitTheme;
|
final theme = context.conduitTheme;
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
final transportAvailability = ref.watch(socketTransportOptionsProvider);
|
||||||
|
var activeTransportMode = settings.socketTransportMode;
|
||||||
|
if (!transportAvailability.allowPolling &&
|
||||||
|
activeTransportMode == 'polling') {
|
||||||
|
activeTransportMode = 'ws';
|
||||||
|
} else if (!transportAvailability.allowWebsocketOnly &&
|
||||||
|
activeTransportMode == 'ws') {
|
||||||
|
activeTransportMode = 'polling';
|
||||||
|
}
|
||||||
|
final transportLabel = activeTransportMode == 'polling'
|
||||||
|
? l10n.transportModePolling
|
||||||
|
: l10n.transportModeWs;
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -402,6 +414,38 @@ class AppCustomizationPage extends ConsumerWidget {
|
|||||||
TextStyle(color: theme.sidebarForeground, fontSize: 18),
|
TextStyle(color: theme.sidebarForeground, fontSize: 18),
|
||||||
),
|
),
|
||||||
const SizedBox(height: Spacing.sm),
|
const SizedBox(height: Spacing.sm),
|
||||||
|
_CustomizationTile(
|
||||||
|
leading: _buildIconBadge(
|
||||||
|
context,
|
||||||
|
UiUtils.platformIcon(
|
||||||
|
ios: CupertinoIcons.arrow_2_circlepath,
|
||||||
|
android: Icons.sync,
|
||||||
|
),
|
||||||
|
color: theme.buttonPrimary,
|
||||||
|
),
|
||||||
|
title: l10n.transportMode,
|
||||||
|
subtitle: transportLabel,
|
||||||
|
trailing:
|
||||||
|
transportAvailability.allowPolling &&
|
||||||
|
transportAvailability.allowWebsocketOnly
|
||||||
|
? _buildValueBadge(context, transportLabel)
|
||||||
|
: null,
|
||||||
|
onTap:
|
||||||
|
transportAvailability.allowPolling &&
|
||||||
|
transportAvailability.allowWebsocketOnly
|
||||||
|
? () => _showTransportModeSheet(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
settings,
|
||||||
|
allowPolling: transportAvailability.allowPolling,
|
||||||
|
allowWebsocketOnly: transportAvailability.allowWebsocketOnly,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
showChevron:
|
||||||
|
transportAvailability.allowPolling &&
|
||||||
|
transportAvailability.allowWebsocketOnly,
|
||||||
|
),
|
||||||
|
const SizedBox(height: Spacing.sm),
|
||||||
_CustomizationTile(
|
_CustomizationTile(
|
||||||
leading: _buildIconBadge(
|
leading: _buildIconBadge(
|
||||||
context,
|
context,
|
||||||
@@ -1217,6 +1261,148 @@ class AppCustomizationPage extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _showTransportModeSheet(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
AppSettings settings, {
|
||||||
|
required bool allowPolling,
|
||||||
|
required bool allowWebsocketOnly,
|
||||||
|
}) async {
|
||||||
|
final theme = context.conduitTheme;
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
var current = settings.socketTransportMode;
|
||||||
|
|
||||||
|
final options = <({String value, String title, String subtitle})>[];
|
||||||
|
if (allowPolling) {
|
||||||
|
options.add((
|
||||||
|
value: 'polling',
|
||||||
|
title: l10n.transportModePolling,
|
||||||
|
subtitle: l10n.transportModePollingInfo,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if (allowWebsocketOnly) {
|
||||||
|
options.add((
|
||||||
|
value: 'ws',
|
||||||
|
title: l10n.transportModeWs,
|
||||||
|
subtitle: l10n.transportModeWsInfo,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.any((option) => option.value == current)) {
|
||||||
|
current = options.first.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: theme.sidebarBackground,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(
|
||||||
|
top: Radius.circular(AppBorderRadius.modal),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
builder: (sheetContext) {
|
||||||
|
return SafeArea(
|
||||||
|
top: false,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: Spacing.lg,
|
||||||
|
vertical: Spacing.md,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
l10n.transportMode,
|
||||||
|
style:
|
||||||
|
theme.headingSmall?.copyWith(
|
||||||
|
color: theme.sidebarForeground,
|
||||||
|
) ??
|
||||||
|
TextStyle(
|
||||||
|
color: theme.sidebarForeground,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Icons.close, color: theme.iconPrimary),
|
||||||
|
onPressed: () => Navigator.of(sheetContext).pop(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
for (var i = 0; i < options.length; i++) ...[
|
||||||
|
() {
|
||||||
|
final option = options[i];
|
||||||
|
final selected = current == option.value;
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
selected ? Icons.check_circle : Icons.circle_outlined,
|
||||||
|
color: selected
|
||||||
|
? theme.buttonPrimary
|
||||||
|
: theme.iconSecondary,
|
||||||
|
),
|
||||||
|
title: Text(option.title),
|
||||||
|
subtitle: Text(option.subtitle),
|
||||||
|
onTap: () {
|
||||||
|
if (!selected) {
|
||||||
|
ref
|
||||||
|
.read(appSettingsProvider.notifier)
|
||||||
|
.setSocketTransportMode(option.value);
|
||||||
|
}
|
||||||
|
Navigator.of(sheetContext).pop();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}(),
|
||||||
|
if (i != options.length - 1) const Divider(height: 1),
|
||||||
|
],
|
||||||
|
const SizedBox(height: Spacing.lg),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildValueBadge(BuildContext context, String label) {
|
||||||
|
final theme = context.conduitTheme;
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: Spacing.md,
|
||||||
|
vertical: Spacing.xs,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.buttonPrimary.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.small),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.buttonPrimary.withValues(alpha: 0.25),
|
||||||
|
width: BorderWidth.thin,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style:
|
||||||
|
theme.bodySmall?.copyWith(
|
||||||
|
color: theme.buttonPrimary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
) ??
|
||||||
|
TextStyle(
|
||||||
|
color: theme.buttonPrimary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildIconBadge(
|
Widget _buildIconBadge(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
IconData icon, {
|
IconData icon, {
|
||||||
|
|||||||
@@ -334,9 +334,9 @@
|
|||||||
"transportMode": "Transportmodus",
|
"transportMode": "Transportmodus",
|
||||||
"transportModeDescription": "Wähle, wie die App für Echtzeit-Updates verbindet.",
|
"transportModeDescription": "Wähle, wie die App für Echtzeit-Updates verbindet.",
|
||||||
"mode": "Modus",
|
"mode": "Modus",
|
||||||
"transportModeAuto": "Auto (Polling + WebSocket)",
|
"transportModePolling": "Polling-Fallback",
|
||||||
"transportModeWs": "Nur WebSocket",
|
"transportModeWs": "Nur WebSocket",
|
||||||
"transportModeAutoInfo": "Robuster in restriktiven Netzwerken. Wechselt nach Möglichkeit zu WebSocket.",
|
"transportModePollingInfo": "Fällt auf HTTP-Polling zurück, wenn WebSockets blockiert sind. Wechselt nach Möglichkeit zu WebSocket.",
|
||||||
"transportModeWsInfo": "Geringerer Overhead, kann jedoch hinter strikten Proxys/Firewalls fehlschlagen.",
|
"transportModeWsInfo": "Geringerer Overhead, kann jedoch hinter strikten Proxys/Firewalls fehlschlagen.",
|
||||||
"websocketConnectionError": "Echtzeit-Verbindung konnte nicht hergestellt werden. Bitte überprüfen Sie Ihr Netzwerk und die Serverkonfiguration.",
|
"websocketConnectionError": "Echtzeit-Verbindung konnte nicht hergestellt werden. Bitte überprüfen Sie Ihr Netzwerk und die Serverkonfiguration.",
|
||||||
"websocketReconnectFailed": "Echtzeit-Verbindung fehlgeschlagen. Streaming funktioniert möglicherweise nicht ordnungsgemäß."
|
"websocketReconnectFailed": "Echtzeit-Verbindung fehlgeschlagen. Streaming funktioniert möglicherweise nicht ordnungsgemäß."
|
||||||
|
|||||||
@@ -694,12 +694,12 @@
|
|||||||
"@transportModeDescription": {"description": "Helper text explaining the transport setting."},
|
"@transportModeDescription": {"description": "Helper text explaining the transport setting."},
|
||||||
"mode": "Mode",
|
"mode": "Mode",
|
||||||
"@mode": {"description": "Form field label for transport mode dropdown."},
|
"@mode": {"description": "Form field label for transport mode dropdown."},
|
||||||
"transportModeAuto": "Auto (Polling + WebSocket)",
|
"transportModePolling": "Polling fallback",
|
||||||
"@transportModeAuto": {"description": "Dropdown option label for automatic transport selection."},
|
"@transportModePolling": {"description": "Dropdown option label for HTTP polling fallback transport."},
|
||||||
"transportModeWs": "WebSocket only",
|
"transportModeWs": "WebSocket only",
|
||||||
"@transportModeWs": {"description": "Dropdown option label for WebSocket-only transport."},
|
"@transportModeWs": {"description": "Dropdown option label for WebSocket-only transport."},
|
||||||
"transportModeAutoInfo": "More robust on restrictive networks. Upgrades to WebSocket when possible.",
|
"transportModePollingInfo": "Falls back to HTTP polling when WebSocket is blocked. Upgrades to WebSocket when possible.",
|
||||||
"@transportModeAutoInfo": {"description": "Footnote text for the Auto transport mode."},
|
"@transportModePollingInfo": {"description": "Footnote text for the polling fallback transport mode."},
|
||||||
"transportModeWsInfo": "Lower overhead, but may fail behind strict proxies/firewalls.",
|
"transportModeWsInfo": "Lower overhead, but may fail behind strict proxies/firewalls.",
|
||||||
"@transportModeWsInfo": {"description": "Footnote text for the WebSocket-only transport mode."},
|
"@transportModeWsInfo": {"description": "Footnote text for the WebSocket-only transport mode."},
|
||||||
"websocketConnectionError": "Unable to establish real-time connection. Please check your network and server configuration.",
|
"websocketConnectionError": "Unable to establish real-time connection. Please check your network and server configuration.",
|
||||||
|
|||||||
@@ -327,9 +327,9 @@
|
|||||||
"transportMode": "Modo de transporte",
|
"transportMode": "Modo de transporte",
|
||||||
"transportModeDescription": "Elige cómo se conecta la aplicación para actualizaciones en tiempo real.",
|
"transportModeDescription": "Elige cómo se conecta la aplicación para actualizaciones en tiempo real.",
|
||||||
"mode": "Modo",
|
"mode": "Modo",
|
||||||
"transportModeAuto": "Automático (Polling + WebSocket)",
|
"transportModePolling": "Polling de respaldo",
|
||||||
"transportModeWs": "Solo WebSocket",
|
"transportModeWs": "Solo WebSocket",
|
||||||
"transportModeAutoInfo": "Más robusto en redes restrictivas. Se actualiza a WebSocket cuando es posible.",
|
"transportModePollingInfo": "Recurrirá a HTTP polling si WebSocket está bloqueado. Se actualizará a WebSocket cuando sea posible.",
|
||||||
"transportModeWsInfo": "Menor sobrecarga, pero puede fallar detrás de proxies/firewalls estrictos.",
|
"transportModeWsInfo": "Menor sobrecarga, pero puede fallar detrás de proxies/firewalls estrictos.",
|
||||||
"websocketConnectionError": "No se puede establecer la conexión en tiempo real. Por favor, verifica tu red y la configuración del servidor.",
|
"websocketConnectionError": "No se puede establecer la conexión en tiempo real. Por favor, verifica tu red y la configuración del servidor.",
|
||||||
"websocketReconnectFailed": "Fallo en la conexión en tiempo real. El streaming podría no funcionar correctamente."
|
"websocketReconnectFailed": "Fallo en la conexión en tiempo real. El streaming podría no funcionar correctamente."
|
||||||
|
|||||||
@@ -334,9 +334,9 @@
|
|||||||
"transportMode": "Mode de transport",
|
"transportMode": "Mode de transport",
|
||||||
"transportModeDescription": "Choisissez comment l'app se connecte pour les mises à jour en temps réel.",
|
"transportModeDescription": "Choisissez comment l'app se connecte pour les mises à jour en temps réel.",
|
||||||
"mode": "Mode",
|
"mode": "Mode",
|
||||||
"transportModeAuto": "Auto (Polling + WebSocket)",
|
"transportModePolling": "Polling de secours",
|
||||||
"transportModeWs": "WebSocket uniquement",
|
"transportModeWs": "WebSocket uniquement",
|
||||||
"transportModeAutoInfo": "Plus robuste sur les réseaux restrictifs. Passe à WebSocket lorsque possible.",
|
"transportModePollingInfo": "Bascule sur HTTP polling lorsque WebSocket est bloqué. Repasse à WebSocket dès que possible.",
|
||||||
"transportModeWsInfo": "Moins de surcharge, mais peut échouer derrière des proxys/firewalls stricts.",
|
"transportModeWsInfo": "Moins de surcharge, mais peut échouer derrière des proxys/firewalls stricts.",
|
||||||
"websocketConnectionError": "Impossible d'établir une connexion en temps réel. Veuillez vérifier votre réseau et la configuration du serveur.",
|
"websocketConnectionError": "Impossible d'établir une connexion en temps réel. Veuillez vérifier votre réseau et la configuration du serveur.",
|
||||||
"websocketReconnectFailed": "Échec de la connexion en temps réel. Le streaming pourrait ne pas fonctionner correctement."
|
"websocketReconnectFailed": "Échec de la connexion en temps réel. Le streaming pourrait ne pas fonctionner correctement."
|
||||||
|
|||||||
@@ -334,9 +334,9 @@
|
|||||||
"transportMode": "Modalità di trasporto",
|
"transportMode": "Modalità di trasporto",
|
||||||
"transportModeDescription": "Scegli come l'app si connette per gli aggiornamenti in tempo reale.",
|
"transportModeDescription": "Scegli come l'app si connette per gli aggiornamenti in tempo reale.",
|
||||||
"mode": "Modalità",
|
"mode": "Modalità",
|
||||||
"transportModeAuto": "Auto (Polling + WebSocket)",
|
"transportModePolling": "Polling di fallback",
|
||||||
"transportModeWs": "Solo WebSocket",
|
"transportModeWs": "Solo WebSocket",
|
||||||
"transportModeAutoInfo": "Più robusto nelle reti restrittive. Passa a WebSocket quando possibile.",
|
"transportModePollingInfo": "Quando WebSocket è bloccato passa a HTTP polling. Torna a WebSocket appena possibile.",
|
||||||
"transportModeWsInfo": "Minore overhead, ma può fallire dietro proxy/firewall restrittivi.",
|
"transportModeWsInfo": "Minore overhead, ma può fallire dietro proxy/firewall restrittivi.",
|
||||||
"websocketConnectionError": "Impossibile stabilire una connessione in tempo reale. Si prega di controllare la rete e la configurazione del server.",
|
"websocketConnectionError": "Impossibile stabilire una connessione in tempo reale. Si prega di controllare la rete e la configurazione del server.",
|
||||||
"websocketReconnectFailed": "Connessione in tempo reale fallita. Lo streaming potrebbe non funzionare correttamente."
|
"websocketReconnectFailed": "Connessione in tempo reale fallita. Lo streaming potrebbe non funzionare correttamente."
|
||||||
|
|||||||
@@ -1826,11 +1826,11 @@ abstract class AppLocalizations {
|
|||||||
/// **'Mode'**
|
/// **'Mode'**
|
||||||
String get mode;
|
String get mode;
|
||||||
|
|
||||||
/// Dropdown option label for automatic transport selection.
|
/// Dropdown option label for HTTP polling fallback transport.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'Auto (Polling + WebSocket)'**
|
/// **'Polling fallback'**
|
||||||
String get transportModeAuto;
|
String get transportModePolling;
|
||||||
|
|
||||||
/// Dropdown option label for WebSocket-only transport.
|
/// Dropdown option label for WebSocket-only transport.
|
||||||
///
|
///
|
||||||
@@ -1838,11 +1838,11 @@ abstract class AppLocalizations {
|
|||||||
/// **'WebSocket only'**
|
/// **'WebSocket only'**
|
||||||
String get transportModeWs;
|
String get transportModeWs;
|
||||||
|
|
||||||
/// Footnote text for the Auto transport mode.
|
/// Footnote text for the polling fallback transport mode.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
/// **'More robust on restrictive networks. Upgrades to WebSocket when possible.'**
|
/// **'Falls back to HTTP polling when WebSocket is blocked. Upgrades to WebSocket when possible.'**
|
||||||
String get transportModeAutoInfo;
|
String get transportModePollingInfo;
|
||||||
|
|
||||||
/// Footnote text for the WebSocket-only transport mode.
|
/// Footnote text for the WebSocket-only transport mode.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -953,14 +953,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
String get mode => 'Modus';
|
String get mode => 'Modus';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get transportModeAuto => 'Auto (Polling + WebSocket)';
|
String get transportModePolling => 'Polling-Fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get transportModeWs => 'Nur WebSocket';
|
String get transportModeWs => 'Nur WebSocket';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get transportModeAutoInfo =>
|
String get transportModePollingInfo =>
|
||||||
'Robuster in restriktiven Netzwerken. Wechselt nach Möglichkeit zu WebSocket.';
|
'Fällt auf HTTP-Polling zurück, wenn WebSockets blockiert sind. Wechselt nach Möglichkeit zu WebSocket.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get transportModeWsInfo =>
|
String get transportModeWsInfo =>
|
||||||
|
|||||||
@@ -945,14 +945,14 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get mode => 'Mode';
|
String get mode => 'Mode';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get transportModeAuto => 'Auto (Polling + WebSocket)';
|
String get transportModePolling => 'Polling fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get transportModeWs => 'WebSocket only';
|
String get transportModeWs => 'WebSocket only';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get transportModeAutoInfo =>
|
String get transportModePollingInfo =>
|
||||||
'More robust on restrictive networks. Upgrades to WebSocket when possible.';
|
'Falls back to HTTP polling when WebSocket is blocked. Upgrades to WebSocket when possible.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get transportModeWsInfo =>
|
String get transportModeWsInfo =>
|
||||||
|
|||||||
@@ -959,14 +959,14 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
String get mode => 'Mode';
|
String get mode => 'Mode';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get transportModeAuto => 'Auto (Polling + WebSocket)';
|
String get transportModePolling => 'Polling de secours';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get transportModeWs => 'WebSocket uniquement';
|
String get transportModeWs => 'WebSocket uniquement';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get transportModeAutoInfo =>
|
String get transportModePollingInfo =>
|
||||||
'Plus robuste sur les réseaux restrictifs. Passe à WebSocket lorsque possible.';
|
'Bascule sur HTTP polling lorsque WebSocket est bloqué. Repasse à WebSocket dès que possible.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get transportModeWsInfo =>
|
String get transportModeWsInfo =>
|
||||||
|
|||||||
@@ -948,14 +948,14 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
String get mode => 'Modalità';
|
String get mode => 'Modalità';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get transportModeAuto => 'Auto (Polling + WebSocket)';
|
String get transportModePolling => 'Polling di fallback';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get transportModeWs => 'Solo WebSocket';
|
String get transportModeWs => 'Solo WebSocket';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get transportModeAutoInfo =>
|
String get transportModePollingInfo =>
|
||||||
'Più robusto nelle reti restrittive. Passa a WebSocket quando possibile.';
|
'Quando WebSocket è bloccato passa a HTTP polling. Torna a WebSocket appena possibile.';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get transportModeWsInfo =>
|
String get transportModeWsInfo =>
|
||||||
|
|||||||
@@ -327,9 +327,9 @@
|
|||||||
"transportMode": "Transportmodus",
|
"transportMode": "Transportmodus",
|
||||||
"transportModeDescription": "Kies hoe de app verbindt voor realtime updates.",
|
"transportModeDescription": "Kies hoe de app verbindt voor realtime updates.",
|
||||||
"mode": "Modus",
|
"mode": "Modus",
|
||||||
"transportModeAuto": "Automatisch (Polling + WebSocket)",
|
"transportModePolling": "Polling-fallback",
|
||||||
"transportModeWs": "Alleen WebSocket",
|
"transportModeWs": "Alleen WebSocket",
|
||||||
"transportModeAutoInfo": "Robuuster op beperkende netwerken. Upgrade naar WebSocket indien mogelijk.",
|
"transportModePollingInfo": "Valt terug op HTTP-polling wanneer WebSocket geblokkeerd is. Upgrade naar WebSocket zodra dat kan.",
|
||||||
"transportModeWsInfo": "Lagere overhead, maar kan mislukken achter strikte proxies/firewalls.",
|
"transportModeWsInfo": "Lagere overhead, maar kan mislukken achter strikte proxies/firewalls.",
|
||||||
"websocketConnectionError": "Kan geen realtime verbinding maken. Controleer uw netwerk en serverconfiguratie.",
|
"websocketConnectionError": "Kan geen realtime verbinding maken. Controleer uw netwerk en serverconfiguratie.",
|
||||||
"websocketReconnectFailed": "Realtime verbinding mislukt. Streaming werkt mogelijk niet goed."
|
"websocketReconnectFailed": "Realtime verbinding mislukt. Streaming werkt mogelijk niet goed."
|
||||||
|
|||||||
@@ -327,9 +327,9 @@
|
|||||||
"transportMode": "Режим транспорта",
|
"transportMode": "Режим транспорта",
|
||||||
"transportModeDescription": "Выберите, как приложение подключается для обновлений в реальном времени.",
|
"transportModeDescription": "Выберите, как приложение подключается для обновлений в реальном времени.",
|
||||||
"mode": "Режим",
|
"mode": "Режим",
|
||||||
"transportModeAuto": "Авто (опрос + WebSocket)",
|
"transportModePolling": "Опрос (резерв)",
|
||||||
"transportModeWs": "Только WebSocket",
|
"transportModeWs": "Только WebSocket",
|
||||||
"transportModeAutoInfo": "Более надежен в ограничительных сетях. Переходит на WebSocket, когда это возможно.",
|
"transportModePollingInfo": "Переходит на HTTP-опрос, если WebSocket заблокирован. Возвращается к WebSocket, когда это возможно.",
|
||||||
"transportModeWsInfo": "Меньше накладных расходов, но может не работать за строгими прокси/брандмауэрами.",
|
"transportModeWsInfo": "Меньше накладных расходов, но может не работать за строгими прокси/брандмауэрами.",
|
||||||
"websocketConnectionError": "Не удалось установить соединение в реальном времени. Пожалуйста, проверьте сеть и конфигурацию сервера.",
|
"websocketConnectionError": "Не удалось установить соединение в реальном времени. Пожалуйста, проверьте сеть и конфигурацию сервера.",
|
||||||
"websocketReconnectFailed": "Сбой соединения в реальном времени. Потоковая передача может работать неправильно."
|
"websocketReconnectFailed": "Сбой соединения в реальном времени. Потоковая передача может работать неправильно."
|
||||||
|
|||||||
@@ -327,9 +327,9 @@
|
|||||||
"transportMode": "传输模式",
|
"transportMode": "传输模式",
|
||||||
"transportModeDescription": "选择应用如何连接以进行实时更新。",
|
"transportModeDescription": "选择应用如何连接以进行实时更新。",
|
||||||
"mode": "模式",
|
"mode": "模式",
|
||||||
"transportModeAuto": "自动(轮询 + WebSocket)",
|
"transportModePolling": "轮询回退",
|
||||||
"transportModeWs": "仅 WebSocket",
|
"transportModeWs": "仅 WebSocket",
|
||||||
"transportModeAutoInfo": "在限制性网络上更稳健。在可能的情况下升级到 WebSocket。",
|
"transportModePollingInfo": "当 WebSocket 被阻止时改用 HTTP 轮询,在条件允许时切换回 WebSocket。",
|
||||||
"transportModeWsInfo": "开销较低,但可能在严格的代理/防火墙后失败。",
|
"transportModeWsInfo": "开销较低,但可能在严格的代理/防火墙后失败。",
|
||||||
"websocketConnectionError": "无法建立实时连接。请检查您的网络和服务器配置。",
|
"websocketConnectionError": "无法建立实时连接。请检查您的网络和服务器配置。",
|
||||||
"websocketReconnectFailed": "实时连接失败。流式传输可能无法正常工作。"
|
"websocketReconnectFailed": "实时连接失败。流式传输可能无法正常工作。"
|
||||||
|
|||||||
Reference in New Issue
Block a user