Files
arbiter/useragent/lib/screens/server_info_setup.dart
2026-03-15 22:10:24 +01:00

295 lines
8.4 KiB
Dart

import 'package:arbiter/features/connection/arbiter_url.dart';
import 'package:arbiter/features/connection/server_info_storage.dart';
import 'package:arbiter/providers/connection/bootstrap_token.dart';
import 'package:arbiter/providers/server_info.dart';
import 'package:arbiter/router.gr.dart';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:sizer/sizer.dart';
@RoutePage()
class ServerInfoSetupScreen extends HookConsumerWidget {
const ServerInfoSetupScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final storedServerInfo = ref.watch(serverInfoProvider);
final resolvedServerInfo = storedServerInfo.asData?.value;
final controller = useTextEditingController();
final errorText = useState<String?>(null);
final isSaving = useState(false);
useEffect(() {
final serverInfo = resolvedServerInfo;
if (serverInfo != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
context.router.replace(ServerConnectionRoute());
}
});
}
return null;
}, [context, resolvedServerInfo]);
Future<void> saveRemoteServerInfo() async {
errorText.value = null;
isSaving.value = true;
try {
final arbiterUrl = ArbiterUrl.parse(controller.text.trim());
// set token before triggering reconnection by updating server info
if (arbiterUrl.bootstrapToken != null) {
ref
.read(bootstrapTokenProvider.notifier)
.set(arbiterUrl.bootstrapToken!);
}
await ref
.read(serverInfoProvider.notifier)
.save(
address: arbiterUrl.host,
port: arbiterUrl.port,
caCert: arbiterUrl.caCert,
);
if (context.mounted) {
context.router.replace(
ServerConnectionRoute(arbiterUrl: controller.text.trim()),
);
}
} on FormatException catch (error) {
errorText.value = error.message;
} catch (_) {
errorText.value = 'Failed to store connection settings.';
} finally {
isSaving.value = false;
}
}
if (storedServerInfo.isLoading) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
if (storedServerInfo.hasError) {
return Scaffold(
appBar: AppBar(title: const Text('Server Info Setup')),
body: Center(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: 6.w),
child: Text(
'Failed to load stored server info.',
style: Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
),
),
);
}
final serverInfo = resolvedServerInfo;
if (serverInfo != null) {
return _RedirectingView(serverInfo: serverInfo);
}
return Scaffold(
appBar: AppBar(title: const Text('Server Info Setup')),
body: LayoutBuilder(
builder: (context, constraints) {
final useRowLayout = constraints.maxWidth > constraints.maxHeight;
final gap = 2.h;
final horizontalPadding = 6.w;
final verticalPadding = 3.h;
final options = [
const _OptionCard(
title: 'Local',
subtitle:
'Will start and connect to a local service in a future update.',
enabled: false,
child: SizedBox.shrink(),
),
_OptionCard(
title: 'Remote',
subtitle:
'Paste an Arbiter URL to store the server address, port, and CA fingerprint.',
child: _RemoteConnectionForm(
controller: controller,
errorText: errorText.value,
isSaving: isSaving.value,
onSave: saveRemoteServerInfo,
),
),
];
return ListView(
padding: EdgeInsets.symmetric(
horizontal: horizontalPadding,
vertical: verticalPadding,
),
children: [
_SetupHeader(gap: gap),
SizedBox(height: gap),
useRowLayout
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: options[0]),
SizedBox(width: 3.w),
Expanded(child: options[1]),
],
)
: Column(
children: [
options[0],
SizedBox(height: gap),
options[1],
],
),
],
);
},
),
);
}
}
class _SetupHeader extends StatelessWidget {
const _SetupHeader({required this.gap});
final double gap;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Choose how this user agent should reach Arbiter.',
style: Theme.of(context).textTheme.headlineSmall,
),
SizedBox(height: gap * 0.5),
Text(
'Remote accepts the shareable Arbiter URL emitted by the server.',
style: Theme.of(context).textTheme.bodyMedium,
),
],
);
}
}
class _RedirectingView extends StatelessWidget {
const _RedirectingView({required this.serverInfo});
final StoredServerInfo serverInfo;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
SizedBox(height: 2.h),
Text('Using saved server ${serverInfo.address}:${serverInfo.port}'),
],
),
),
);
}
}
class _RemoteConnectionForm extends StatelessWidget {
const _RemoteConnectionForm({
required this.controller,
required this.errorText,
required this.isSaving,
required this.onSave,
});
final TextEditingController controller;
final String? errorText;
final bool isSaving;
final VoidCallback onSave;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: controller,
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Arbiter URL',
hintText: 'arbiter://host:port?cert=...',
),
minLines: 2,
maxLines: 4,
),
if (errorText != null) ...[
SizedBox(height: 1.5.h),
Text(
errorText!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
SizedBox(height: 2.h),
Align(
alignment: Alignment.centerLeft,
child: FilledButton(
onPressed: isSaving ? null : onSave,
child: Text(isSaving ? 'Saving...' : 'Save connection'),
),
),
],
);
}
}
class _OptionCard extends StatelessWidget {
const _OptionCard({
required this.title,
required this.subtitle,
required this.child,
this.enabled = true,
});
final String title;
final String subtitle;
final Widget child;
final bool enabled;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
child: Padding(
padding: EdgeInsets.all(2.h),
child: Opacity(
opacity: enabled ? 1 : 0.55,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(title, style: theme.textTheme.titleLarge),
if (!enabled) ...[
SizedBox(width: 2.w),
const Chip(label: Text('Coming soon')),
],
],
),
SizedBox(height: 1.h),
Text(subtitle, style: theme.textTheme.bodyMedium),
if (enabled) ...[SizedBox(height: 2.h), child],
],
),
),
),
);
}
}