feat(useragent): added connection info setup screen
This commit is contained in:
56
useragent/lib/features/arbiter_url.dart
Normal file
56
useragent/lib/features/arbiter_url.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
import 'dart:convert';
|
||||
|
||||
class ArbiterUrl {
|
||||
const ArbiterUrl({
|
||||
required this.host,
|
||||
required this.port,
|
||||
required this.caCert,
|
||||
this.bootstrapToken,
|
||||
});
|
||||
|
||||
final String host;
|
||||
final int port;
|
||||
final List<int> caCert;
|
||||
final String? bootstrapToken;
|
||||
|
||||
static const _scheme = 'arbiter';
|
||||
static const _certQueryKey = 'cert';
|
||||
static const _bootstrapTokenQueryKey = 'bootstrap_token';
|
||||
|
||||
static ArbiterUrl parse(String value) {
|
||||
final uri = Uri.tryParse(value);
|
||||
if (uri == null || uri.scheme != _scheme) {
|
||||
throw const FormatException("Invalid URL scheme, expected 'arbiter://'");
|
||||
}
|
||||
|
||||
if (uri.host.isEmpty) {
|
||||
throw const FormatException('Missing host in URL');
|
||||
}
|
||||
|
||||
if (!uri.hasPort) {
|
||||
throw const FormatException('Missing port in URL');
|
||||
}
|
||||
|
||||
final cert = uri.queryParameters[_certQueryKey];
|
||||
if (cert == null || cert.isEmpty) {
|
||||
throw const FormatException("Missing 'cert' query parameter in URL");
|
||||
}
|
||||
|
||||
final decodedCert = _decodeCert(cert);
|
||||
|
||||
return ArbiterUrl(
|
||||
host: uri.host,
|
||||
port: uri.port,
|
||||
caCert: decodedCert,
|
||||
bootstrapToken: uri.queryParameters[_bootstrapTokenQueryKey],
|
||||
);
|
||||
}
|
||||
|
||||
static List<int> _decodeCert(String cert) {
|
||||
try {
|
||||
return base64Url.decode(base64Url.normalize(cert));
|
||||
} on FormatException catch (error) {
|
||||
throw FormatException("Invalid base64 in 'cert' query parameter: ${error.message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
3
useragent/lib/features/connection/connection.dart
Normal file
3
useragent/lib/features/connection/connection.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
|
||||
|
||||
class Connection {}
|
||||
@@ -1,6 +1,3 @@
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
enum KeyAlgorithm {
|
||||
rsa, ecdsa, ed25519
|
||||
}
|
||||
@@ -16,4 +13,4 @@ abstract class KeyHandle {
|
||||
abstract class KeyManager {
|
||||
Future<KeyHandle?> get();
|
||||
Future<KeyHandle> create();
|
||||
}
|
||||
}
|
||||
|
||||
67
useragent/lib/features/server_info_storage.dart
Normal file
67
useragent/lib/features/server_info_storage.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
class StoredServerInfo {
|
||||
const StoredServerInfo({
|
||||
required this.address,
|
||||
required this.port,
|
||||
required this.caCertFingerprint,
|
||||
});
|
||||
|
||||
final String address;
|
||||
final int port;
|
||||
final String caCertFingerprint;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'address': address,
|
||||
'port': port,
|
||||
'caCertFingerprint': caCertFingerprint,
|
||||
};
|
||||
|
||||
factory StoredServerInfo.fromJson(Map<String, dynamic> json) {
|
||||
return StoredServerInfo(
|
||||
address: json['address'] as String,
|
||||
port: json['port'] as int,
|
||||
caCertFingerprint: json['caCertFingerprint'] as String,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
abstract class ServerInfoStorage {
|
||||
Future<StoredServerInfo?> load();
|
||||
Future<void> save(StoredServerInfo serverInfo);
|
||||
Future<void> clear();
|
||||
}
|
||||
|
||||
class SecureServerInfoStorage implements ServerInfoStorage {
|
||||
static const _storageKey = 'server_info';
|
||||
|
||||
const SecureServerInfoStorage();
|
||||
|
||||
static const _storage = FlutterSecureStorage();
|
||||
|
||||
@override
|
||||
Future<StoredServerInfo?> load() async {
|
||||
final rawValue = await _storage.read(key: _storageKey);
|
||||
if (rawValue == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final decoded = jsonDecode(rawValue) as Map<String, dynamic>;
|
||||
return StoredServerInfo.fromJson(decoded);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> save(StoredServerInfo serverInfo) {
|
||||
return _storage.write(
|
||||
key: _storageKey,
|
||||
value: jsonEncode(serverInfo.toJson()),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> clear() {
|
||||
return _storage.delete(key: _storageKey);
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,35 @@
|
||||
import 'package:arbiter/router.dart';
|
||||
import 'package:flutter/material.dart' hide Router;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:sizer/sizer.dart';
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
runApp(
|
||||
ProviderScope(
|
||||
child: MaterialApp.router(
|
||||
routerConfig: Router().config(),
|
||||
),
|
||||
),
|
||||
);
|
||||
runApp(const ProviderScope(child: App()));
|
||||
}
|
||||
|
||||
class App extends StatefulWidget {
|
||||
const App({super.key});
|
||||
|
||||
@override
|
||||
State<App> createState() => _AppState();
|
||||
}
|
||||
|
||||
class _AppState extends State<App> {
|
||||
late final Router _router;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_router = Router();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Sizer(
|
||||
builder: (context, orientation, deviceType) {
|
||||
return MaterialApp.router(routerConfig: _router.config());
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
51
useragent/lib/providers/server_info.dart
Normal file
51
useragent/lib/providers/server_info.dart
Normal file
@@ -0,0 +1,51 @@
|
||||
import 'package:arbiter/features/server_info_storage.dart';
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'server_info.g.dart';
|
||||
|
||||
@riverpod
|
||||
ServerInfoStorage serverInfoStorage(Ref ref) {
|
||||
return const SecureServerInfoStorage();
|
||||
}
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class ServerInfo extends _$ServerInfo {
|
||||
@override
|
||||
Future<StoredServerInfo?> build() {
|
||||
final storage = ref.watch(serverInfoStorageProvider);
|
||||
return storage.load();
|
||||
}
|
||||
|
||||
Future<void> save({
|
||||
required String address,
|
||||
required int port,
|
||||
required List<int> caCert,
|
||||
}) async {
|
||||
final storage = ref.read(serverInfoStorageProvider);
|
||||
final fingerprint = await _fingerprint(caCert);
|
||||
final serverInfo = StoredServerInfo(
|
||||
address: address,
|
||||
port: port,
|
||||
caCertFingerprint: fingerprint,
|
||||
);
|
||||
|
||||
state = await AsyncValue.guard(() async {
|
||||
await storage.save(serverInfo);
|
||||
return serverInfo;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> clear() async {
|
||||
final storage = ref.read(serverInfoStorageProvider);
|
||||
state = await AsyncValue.guard(() async {
|
||||
await storage.clear();
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
Future<String> _fingerprint(List<int> caCert) async {
|
||||
final digest = await Sha256().hash(caCert);
|
||||
return digest.bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
}
|
||||
102
useragent/lib/providers/server_info.g.dart
Normal file
102
useragent/lib/providers/server_info.g.dart
Normal file
@@ -0,0 +1,102 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'server_info.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// RiverpodGenerator
|
||||
// **************************************************************************
|
||||
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
// ignore_for_file: type=lint, type=warning
|
||||
|
||||
@ProviderFor(serverInfoStorage)
|
||||
final serverInfoStorageProvider = ServerInfoStorageProvider._();
|
||||
|
||||
final class ServerInfoStorageProvider
|
||||
extends
|
||||
$FunctionalProvider<
|
||||
ServerInfoStorage,
|
||||
ServerInfoStorage,
|
||||
ServerInfoStorage
|
||||
>
|
||||
with $Provider<ServerInfoStorage> {
|
||||
ServerInfoStorageProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'serverInfoStorageProvider',
|
||||
isAutoDispose: true,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$serverInfoStorageHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
$ProviderElement<ServerInfoStorage> $createElement(
|
||||
$ProviderPointer pointer,
|
||||
) => $ProviderElement(pointer);
|
||||
|
||||
@override
|
||||
ServerInfoStorage create(Ref ref) {
|
||||
return serverInfoStorage(ref);
|
||||
}
|
||||
|
||||
/// {@macro riverpod.override_with_value}
|
||||
Override overrideWithValue(ServerInfoStorage value) {
|
||||
return $ProviderOverride(
|
||||
origin: this,
|
||||
providerOverride: $SyncValueProvider<ServerInfoStorage>(value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _$serverInfoStorageHash() => r'fc06865e7314b1a2493c5de1a9347923a3d21c5c';
|
||||
|
||||
@ProviderFor(ServerInfo)
|
||||
final serverInfoProvider = ServerInfoProvider._();
|
||||
|
||||
final class ServerInfoProvider
|
||||
extends $AsyncNotifierProvider<ServerInfo, StoredServerInfo?> {
|
||||
ServerInfoProvider._()
|
||||
: super(
|
||||
from: null,
|
||||
argument: null,
|
||||
retry: null,
|
||||
name: r'serverInfoProvider',
|
||||
isAutoDispose: false,
|
||||
dependencies: null,
|
||||
$allTransitiveDependencies: null,
|
||||
);
|
||||
|
||||
@override
|
||||
String debugGetCreateSourceHash() => _$serverInfoHash();
|
||||
|
||||
@$internal
|
||||
@override
|
||||
ServerInfo create() => ServerInfo();
|
||||
}
|
||||
|
||||
String _$serverInfoHash() => r'6e94f52de03259695a2166b766004eec60ff45fa';
|
||||
|
||||
abstract class _$ServerInfo extends $AsyncNotifier<StoredServerInfo?> {
|
||||
FutureOr<StoredServerInfo?> build();
|
||||
@$mustCallSuper
|
||||
@override
|
||||
void runBuild() {
|
||||
final ref =
|
||||
this.ref as $Ref<AsyncValue<StoredServerInfo?>, StoredServerInfo?>;
|
||||
final element =
|
||||
ref.element
|
||||
as $ClassProviderElement<
|
||||
AnyNotifier<AsyncValue<StoredServerInfo?>, StoredServerInfo?>,
|
||||
AsyncValue<StoredServerInfo?>,
|
||||
Object?,
|
||||
Object?
|
||||
>;
|
||||
element.handleCreate(ref, build);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
import 'package:arbiter/screens/dashboard/about.dart';
|
||||
import 'package:arbiter/screens/dashboard/calc.dart';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart';
|
||||
|
||||
import 'router.gr.dart';
|
||||
|
||||
@@ -11,6 +7,7 @@ class Router extends RootStackRouter {
|
||||
@override
|
||||
List<AutoRoute> get routes => [
|
||||
AutoRoute(page: Bootstrap.page, path: '/bootstrap', initial: true),
|
||||
AutoRoute(page: ServerInfoSetupRoute.page, path: '/server-info'),
|
||||
|
||||
AutoRoute(
|
||||
page: DashboardRouter.page,
|
||||
|
||||
@@ -13,33 +13,34 @@ import 'package:arbiter/screens/bootstrap.dart' as _i2;
|
||||
import 'package:arbiter/screens/dashboard.dart' as _i4;
|
||||
import 'package:arbiter/screens/dashboard/about.dart' as _i1;
|
||||
import 'package:arbiter/screens/dashboard/calc.dart' as _i3;
|
||||
import 'package:auto_route/auto_route.dart' as _i5;
|
||||
import 'package:arbiter/screens/server_info_setup.dart' as _i5;
|
||||
import 'package:auto_route/auto_route.dart' as _i6;
|
||||
|
||||
/// generated route for
|
||||
/// [_i1.AboutScreen]
|
||||
class AboutRoute extends _i5.PageRouteInfo<void> {
|
||||
const AboutRoute({List<_i5.PageRouteInfo>? children})
|
||||
class AboutRoute extends _i6.PageRouteInfo<void> {
|
||||
const AboutRoute({List<_i6.PageRouteInfo>? children})
|
||||
: super(AboutRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'AboutRoute';
|
||||
|
||||
static _i5.PageInfo page = _i5.PageInfo(
|
||||
static _i6.PageInfo page = _i6.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return _i1.AboutScreen();
|
||||
return const _i1.AboutScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i2.Bootstrap]
|
||||
class Bootstrap extends _i5.PageRouteInfo<void> {
|
||||
const Bootstrap({List<_i5.PageRouteInfo>? children})
|
||||
class Bootstrap extends _i6.PageRouteInfo<void> {
|
||||
const Bootstrap({List<_i6.PageRouteInfo>? children})
|
||||
: super(Bootstrap.name, initialChildren: children);
|
||||
|
||||
static const String name = 'Bootstrap';
|
||||
|
||||
static _i5.PageInfo page = _i5.PageInfo(
|
||||
static _i6.PageInfo page = _i6.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i2.Bootstrap();
|
||||
@@ -49,13 +50,13 @@ class Bootstrap extends _i5.PageRouteInfo<void> {
|
||||
|
||||
/// generated route for
|
||||
/// [_i3.CalcScreen]
|
||||
class CalcRoute extends _i5.PageRouteInfo<void> {
|
||||
const CalcRoute({List<_i5.PageRouteInfo>? children})
|
||||
class CalcRoute extends _i6.PageRouteInfo<void> {
|
||||
const CalcRoute({List<_i6.PageRouteInfo>? children})
|
||||
: super(CalcRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'CalcRoute';
|
||||
|
||||
static _i5.PageInfo page = _i5.PageInfo(
|
||||
static _i6.PageInfo page = _i6.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i3.CalcScreen();
|
||||
@@ -65,16 +66,32 @@ class CalcRoute extends _i5.PageRouteInfo<void> {
|
||||
|
||||
/// generated route for
|
||||
/// [_i4.DashboardRouter]
|
||||
class DashboardRouter extends _i5.PageRouteInfo<void> {
|
||||
const DashboardRouter({List<_i5.PageRouteInfo>? children})
|
||||
class DashboardRouter extends _i6.PageRouteInfo<void> {
|
||||
const DashboardRouter({List<_i6.PageRouteInfo>? children})
|
||||
: super(DashboardRouter.name, initialChildren: children);
|
||||
|
||||
static const String name = 'DashboardRouter';
|
||||
|
||||
static _i5.PageInfo page = _i5.PageInfo(
|
||||
static _i6.PageInfo page = _i6.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i4.DashboardRouter();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [_i5.ServerInfoSetupScreen]
|
||||
class ServerInfoSetupRoute extends _i6.PageRouteInfo<void> {
|
||||
const ServerInfoSetupRoute({List<_i6.PageRouteInfo>? children})
|
||||
: super(ServerInfoSetupRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'ServerInfoSetupRoute';
|
||||
|
||||
static _i6.PageInfo page = _i6.PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const _i5.ServerInfoSetupScreen();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,13 +14,13 @@ class Bootstrap extends HookConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final container = ProviderScope.containerOf( context);
|
||||
final container = ProviderScope.containerOf(context);
|
||||
final completer = useMemoized(() {
|
||||
final completer = Completer<void>();
|
||||
completer.future.then((_) async {
|
||||
if (context.mounted) {
|
||||
final router = AutoRouter.of(context);
|
||||
router.replace(const DashboardRouter());
|
||||
router.replace(const ServerInfoSetupRoute());
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:mtcore/markettakers.dart' as mt;
|
||||
|
||||
|
||||
@RoutePage()
|
||||
class AboutScreen extends StatelessWidget {
|
||||
const AboutScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return mt.AboutScreen(decription: "Arbiter is bla bla bla");
|
||||
|
||||
284
useragent/lib/screens/server_info_setup.dart
Normal file
284
useragent/lib/screens/server_info_setup.dart
Normal file
@@ -0,0 +1,284 @@
|
||||
import 'package:arbiter/features/arbiter_url.dart';
|
||||
import 'package:arbiter/features/server_info_storage.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(const DashboardRouter());
|
||||
}
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}, [context, resolvedServerInfo]);
|
||||
|
||||
Future<void> saveRemoteServerInfo() async {
|
||||
errorText.value = null;
|
||||
isSaving.value = true;
|
||||
|
||||
try {
|
||||
final arbiterUrl = ArbiterUrl.parse(controller.text.trim());
|
||||
await ref
|
||||
.read(serverInfoProvider.notifier)
|
||||
.save(
|
||||
address: arbiterUrl.host,
|
||||
port: arbiterUrl.port,
|
||||
caCert: arbiterUrl.caCert,
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
context.router.replace(const DashboardRouter());
|
||||
}
|
||||
} 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,
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user