Home v2: top bar with Join/Host tabs, content-first layout

Top bar merges window chrome, profile, tabs and page icons. Join: one
compact row plus recent-server tiles with live /health status and a
needs-update badge. Host: reusable MediaPicker (Jellyfin placeholder,
Link, File) filling the width, server settings in a narrow column.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
foxyzandClaude Opus 5.5 committed 2026-10-07 01:04:45 +03:00
1 parent 81cac1214d
commit 0669ec0fc2
12 files changed
+1689 -557

No files matched your search

@@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
import '../../state/session_state.dart';
import 'connect_flow.dart';
import 'ui.dart';
/// The submit button for one [ConnectMode], swapped for a progress bar and
/// Cancel while that attempt runs. Disabled while the other mode is busy.
class ConnectButton extends StatelessWidget {
final ConnectFlow flow;
final ConnectMode mode;
final String label;
final VoidCallback onSubmit;
final bool expand;
const ConnectButton({
super.key,
required this.flow,
required this.mode,
required this.label,
required this.onSubmit,
this.expand = false,
});
@override
Widget build(BuildContext context) {
final busyHere = flow.busy == mode;
if (busyHere) {
return SizedBox(
width: expand ? null : 220,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const LinearProgressIndicator(),
const SizedBox(height: 4),
Row(
children: [
Expanded(
child: Text(mode == ConnectMode.host ? 'Starting server…' : 'Connecting…',
style: Theme.of(context).textTheme.bodySmall),
),
TextButton(onPressed: flow.cancel, child: const Text('Cancel')),
],
),
],
),
);
}
final button = FilledButton(
onPressed: flow.busy != null ? null : onSubmit,
child: Text(label),
);
return expand ? SizedBox(width: double.infinity, child: button) : button;
}
}
/// Last error or notice for [mode], or nothing.
class ConnectFeedback extends StatelessWidget {
final ConnectFlow flow;
final ConnectMode mode;
const ConnectFeedback({super.key, required this.flow, required this.mode});
@override
Widget build(BuildContext context) {
final error = flow.errors[mode];
final notice = flow.notices[mode];
if (error != null) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: ErrorBox(title: error.title, message: error.message),
);
}
if (notice != null) {
return Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(notice,
style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey)),
);
}
return const SizedBox.shrink();
}
}
@@ -0,0 +1,77 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:sync_relay_core/core.dart';
/// Fetches a URL body; injectable so tests fake the server's `/health`.
typedef HealthGetter = Future<String> Function(Uri uri);
const healthPollEvery = Duration(seconds: 10);
const _timeout = Duration(seconds: 4);
/// What a recent-server tile shows: unknown until the first probe answers.
class ServerHealth {
final bool online;
final String name;
final int? proto;
final int viewers;
const ServerHealth.offline()
: online = false,
name = '',
proto = null,
viewers = 0;
const ServerHealth.online({required this.name, this.proto, this.viewers = 0})
: online = true;
/// True when the server speaks another protocol than this build.
bool get needsUpdate =>
online && proto != null && proto != RelayConstants.protoVersion;
/// Lenient parse of `GET /health`; anything unexpected reads as offline.
static ServerHealth parse(String body) {
try {
final j = jsonDecode(body);
if (j is! Map || j['ok'] != true) return const ServerHealth.offline();
final name = j['name'], proto = j['proto'], viewers = j['viewers'];
return ServerHealth.online(
name: name is String ? name.trim() : '',
proto: proto is num ? proto.toInt() : null,
viewers: viewers is num ? viewers.toInt() : 0,
);
} catch (_) {
return const ServerHealth.offline();
}
}
}
Uri healthUri(String host, String port) {
final h = host.trim();
// Bare IPv6 literals need brackets in a URL.
final authority = h.contains(':') && !h.startsWith('[') ? '[$h]' : h;
return Uri.parse('http://$authority:${port.trim()}/health');
}
Future<ServerHealth> probeHealth(String host, String port, HealthGetter get) async {
try {
return ServerHealth.parse(await get(healthUri(host, port)).timeout(_timeout));
} catch (_) {
return const ServerHealth.offline();
}
}
/// Plain dart:io GET; non-200 throws.
Future<String> defaultHealthGet(Uri uri) async {
final client = HttpClient()..connectionTimeout = _timeout;
try {
final req = await client.getUrl(uri);
final res = await req.close().timeout(_timeout);
if (res.statusCode != 200) {
await res.drain<void>();
throw HttpException('status ${res.statusCode}', uri: uri);
}
return await res.transform(utf8.decoder).join().timeout(_timeout);
} finally {
client.close(force: true);
}
}
@@ -1,444 +0,0 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../../state/session_state.dart';
import '../../update/update_controller.dart';
import 'connect_flow.dart';
import 'profile.dart';
import 'ui.dart';
import 'update_indicator.dart';
/// Below this width the Join/Host cards stack.
const _stackBelow = 700.0;
const _videoExtensions = ['mp4', 'mkv', 'webm', 'avi', 'mov', 'm4v', 'ts'];
class HomePage extends StatefulWidget {
final UpdateController? updates;
const HomePage({super.key, this.updates});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final _flow = ConnectFlow();
late final _f = SessionState.instance.lastForm;
late final _joinHost = TextEditingController(text: _f.host);
late final _joinPort = TextEditingController(text: _f.port);
late final _joinSecret = TextEditingController(text: _f.secret);
late final _hostPort = TextEditingController(text: _f.hostPort);
late final _hostSecret = TextEditingController(text: _f.hostSecret);
late final _media =
TextEditingController(text: _f.linkUrl.isNotEmpty ? _f.linkUrl : _f.mediaPath);
late final void Function() _stopKickWatch;
late final void Function() _stopCrashWatch;
bool _dialogOpen = false;
String? _lastKickShown;
@override
void initState() {
super.initState();
_flow.onKicked = _showKickedDialog;
// Back from a session that ended in a kick: say so.
_stopKickWatch = effect(() {
final kick = SessionState.instance.coordinator.kickReason.value;
if (kick == null || kick.isEmpty) {
_lastKickShown = null;
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) => _showKickedDialog(kick));
});
// Back from a session whose hosted server crashed: say so on the Host card.
_stopCrashWatch = effect(() {
final crash = SessionState.instance.serverCrash.value;
if (crash == null) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
SessionState.instance.serverCrash.value = null;
if (mounted) _flow.showError(ConnectMode.host, CardError('Server crashed', crash));
});
});
}
@override
void dispose() {
_stopKickWatch();
_stopCrashWatch();
_flow.dispose();
for (final c in [_joinHost, _joinPort, _joinSecret, _hostPort, _hostSecret, _media]) {
c.dispose();
}
super.dispose();
}
void _join() => _flow.submit(ConnectMode.join, (f) {
f
..host = _joinHost.text
..port = _joinPort.text.trim()
..secret = _joinSecret.text;
});
void _host() => _flow.submit(ConnectMode.host, (f) {
f
..hostPort = _hostPort.text.trim()
..hostSecret = _hostSecret.text
..mediaPath = _media.text.trim()
..linkUrl = '';
});
void _useRecent(RecentServer r) {
if (r.hosted) {
_hostPort.text = r.port;
_hostSecret.text = r.secret;
setState(() {});
return;
}
_joinHost.text = r.host;
_joinPort.text = r.port;
_joinSecret.text = r.secret;
_join();
}
Future<void> _browseMedia() async {
try {
final files = await FilePicker.pickFiles(
dialogTitle: 'Pick media file',
type: FileType.custom,
allowedExtensions: _videoExtensions,
);
final path = files.isEmpty ? null : files.first.path;
if (path != null && mounted) setState(() => _media.text = path);
} catch (e) {
if (!mounted) return;
_flow.notices[ConnectMode.host] =
'File dialog unavailable (${e.runtimeType}) — paste the path instead';
setState(() {});
}
}
Future<void> _showKickedDialog(String reason) async {
if (!mounted || _dialogOpen || reason == _lastKickShown) return;
_lastKickShown = reason;
_dialogOpen = true;
try {
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('You were removed'),
content: Text(reason.isEmpty ? 'The host removed you from the server.' : reason),
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))],
),
);
} finally {
_dialogOpen = false;
}
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: _flow,
builder: (context, _) => PageScroll(
children: [
Row(
children: [
const ProfileChip(),
const Spacer(),
UpdateIndicator(controller: widget.updates),
],
),
const SizedBox(height: 24),
LayoutBuilder(builder: (context, box) {
final stacked = box.maxWidth < _stackBelow;
final join = _joinCard(context, fill: !stacked);
final host = _hostCard(context, fill: !stacked);
if (stacked) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [join, const SizedBox(height: kGap), host],
);
}
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(child: join),
const SizedBox(width: kGap),
Expanded(child: host),
],
),
);
}),
_recents(context),
],
),
);
}
Widget _joinCard(BuildContext context, {required bool fill}) {
return _ConnectCard(
key: const ValueKey('join-card'),
icon: Icons.login,
title: 'Join',
subtitle: 'Connect to a friend’s server.',
mode: ConnectMode.join,
flow: _flow,
action: 'Join',
onSubmit: _join,
fill: fill,
fields: [
Row(
children: [
Expanded(
flex: 3,
child: TextField(
controller: _joinHost,
decoration: fieldDecoration('Address', hint: 'IP or hostname'),
onChanged: (_) => _flow.clearError(ConnectMode.join),
onSubmitted: (_) => _join(),
),
),
const SizedBox(width: 12),
Expanded(
child: TextField(
controller: _joinPort,
decoration: fieldDecoration('Port'),
keyboardType: TextInputType.number,
onSubmitted: (_) => _join(),
),
),
],
),
TextField(
controller: _joinSecret,
obscureText: true,
decoration: fieldDecoration('Password (optional)'),
onSubmitted: (_) => _join(),
),
],
);
}
Widget _hostCard(BuildContext context, {required bool fill}) {
return _ConnectCard(
key: const ValueKey('host-card'),
icon: Icons.cast,
title: 'Host',
subtitle: 'Run the server here. Your friends need this port open.',
mode: ConnectMode.host,
flow: _flow,
action: 'Host',
onSubmit: _host,
fill: fill,
fields: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 110,
child: TextField(
controller: _hostPort,
decoration: fieldDecoration('Port'),
keyboardType: TextInputType.number,
onSubmitted: (_) => _host(),
),
),
const SizedBox(width: 12),
Expanded(
child: ListenableBuilder(
listenable: _hostSecret,
builder: (context, _) => TextField(
controller: _hostSecret,
obscureText: true,
decoration: fieldDecoration('Password (optional)').copyWith(
helperText: _hostSecret.text.isEmpty
? 'No password — anyone can join'
: null,
),
onSubmitted: (_) => _host(),
),
),
),
],
),
TextField(
controller: _media,
decoration: fieldDecoration(
'Media',
hint: 'File path or http(s) link — optional',
suffix: Padding(
padding: const EdgeInsets.only(right: 4),
child: TextButton(onPressed: _browseMedia, child: const Text('Browse')),
),
),
onSubmitted: (_) => _host(),
),
],
);
}
Widget _recents(BuildContext context) {
return SignalBuilder(builder: (context) {
final list = SessionState.instance.recents.value;
if (list.isEmpty) return const SizedBox.shrink();
final busy = _flow.busy != null;
return Padding(
padding: const EdgeInsets.only(top: 24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Recent',
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(color: Colors.grey)),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final r in list)
InputChip(
key: ValueKey('recent-${r.key}'),
avatar: Icon(r.hosted ? Icons.cast : Icons.dns_outlined, size: 18),
label: Text(r.hosted ? 'Hosted on :${r.port}' : '${r.host}:${r.port}'),
tooltip: r.hosted ? 'Fill the Host card' : 'Join ${r.host}:${r.port}',
isEnabled: !busy,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
onPressed: () => _useRecent(r),
deleteButtonTooltipMessage: 'Forget',
onDeleted: () => SessionState.instance.forgetServer(r),
),
],
),
],
),
);
});
}
}
class _ConnectCard extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final ConnectMode mode;
final ConnectFlow flow;
final String action;
final VoidCallback onSubmit;
final List<Widget> fields;
// Side by side: push the button to the bottom so both cards line up.
final bool fill;
const _ConnectCard({
super.key,
required this.icon,
required this.title,
required this.subtitle,
required this.mode,
required this.flow,
required this.action,
required this.onSubmit,
required this.fields,
required this.fill,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final busyHere = flow.busy == mode;
final busyElsewhere = flow.busy != null && !busyHere;
final error = flow.errors[mode];
final notice = flow.notices[mode];
return Panel(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(icon, size: 22, color: scheme.primary),
const SizedBox(width: 10),
Text(title, style: theme.textTheme.titleLarge),
],
),
const SizedBox(height: 4),
Text(subtitle, style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey)),
const SizedBox(height: kGap),
AbsorbPointer(
absorbing: busyHere,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 12,
children: fields,
),
),
if (error != null) ...[
const SizedBox(height: 12),
_ErrorBox(error: error),
] else if (notice != null) ...[
const SizedBox(height: 12),
Text(notice, style: theme.textTheme.bodySmall?.copyWith(color: Colors.grey)),
],
if (fill) const Spacer(),
const SizedBox(height: kGap),
if (busyHere) ...[
const LinearProgressIndicator(),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Text(mode == ConnectMode.host ? 'Starting server…' : 'Connecting…',
style: theme.textTheme.bodySmall),
),
TextButton(onPressed: flow.cancel, child: const Text('Cancel')),
],
),
] else
Align(
alignment: Alignment.centerRight,
child: FilledButton(
onPressed: busyElsewhere ? null : onSubmit,
child: Text(action),
),
),
],
),
);
}
}
class _ErrorBox extends StatelessWidget {
final CardError error;
const _ErrorBox({required this.error});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: scheme.errorContainer.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(8),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.error_outline, size: 18, color: scheme.error),
const SizedBox(width: 8),
Expanded(
child: SelectableText.rich(
TextSpan(children: [
TextSpan(
text: '${error.title}\n',
style: const TextStyle(fontWeight: FontWeight.w600)),
TextSpan(text: error.message),
]),
style: const TextStyle(fontSize: 13),
),
),
],
),
);
}
}
@@ -1,60 +1,97 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:signals/signals_flutter.dart';
import '../../state/session_state.dart';
import '../../state/settings.dart';
import '../../update/update_controller.dart';
import '../../widgets/window_chrome_bar.dart';
import 'about_page.dart';
import 'home_page.dart';
import 'connect_flow.dart';
import 'health_probe.dart';
import 'host_tab.dart';
import 'integrations_page.dart';
import 'join_tab.dart';
import 'profile.dart';
import 'settings_page.dart';
import 'ui.dart';
import 'top_bar.dart';
import 'update_indicator.dart';
/// Everything outside a session: Home (profile, Join/Host, recents),
/// Settings, Integrations, About behind a slim rail.
/// Everything outside a session: one top bar (profile, Join/Host tabs,
/// Settings/Integrations/About, window buttons) over a full-width body.
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return const Scaffold(
body: Column(
children: [
WindowChromeBar(title: appName),
Expanded(child: HomeShell()),
],
),
);
return const Scaffold(body: HomeShell(windowControls: true));
}
}
/// [HomeScreen] minus the window chrome (which needs window_manager), so
/// tests can pump it.
/// [HomeScreen]'s content. Tests pump it with [windowControls] off (the
/// drag area and window buttons need window_manager) and a fake [healthGet].
class HomeShell extends StatefulWidget {
final UpdateController? updates;
final bool autoUpdateCheck;
const HomeShell({super.key, this.updates, this.autoUpdateCheck = true});
final bool windowControls;
final HealthGetter healthGet;
const HomeShell({
super.key,
this.updates,
this.autoUpdateCheck = true,
this.windowControls = false,
this.healthGet = defaultHealthGet,
});
@override
State<HomeShell> createState() => _HomeShellState();
}
class _HomeShellState extends State<HomeShell> {
int _tab = 0;
static const _destinations = [
(Icons.home_outlined, Icons.home, 'Home'),
(Icons.settings_outlined, Icons.settings, 'Settings'),
(Icons.extension_outlined, Icons.extension, 'Integrations'),
(Icons.info_outline, Icons.info, 'About'),
];
HomeView _view = HomeView.join;
final _flow = ConnectFlow();
late final _join = JoinFields(SessionState.instance.lastForm);
late final _host = HostFields(SessionState.instance.lastForm);
late final void Function() _stopKickWatch;
late final void Function() _stopCrashWatch;
bool _dialogOpen = false;
String? _lastKickShown;
@override
void initState() {
super.initState();
Profile.instance.ensureLoaded();
if (widget.autoUpdateCheck) _autoCheck();
_flow.onKicked = _showKickedDialog;
// Back from a session that ended in a kick: say so.
_stopKickWatch = effect(() {
final kick = SessionState.instance.coordinator.kickReason.value;
if (kick == null || kick.isEmpty) {
_lastKickShown = null;
return;
}
WidgetsBinding.instance.addPostFrameCallback((_) => _showKickedDialog(kick));
});
// Back from a session whose hosted server crashed: say so on the Host tab.
_stopCrashWatch = effect(() {
final crash = SessionState.instance.serverCrash.value;
if (crash == null) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
SessionState.instance.serverCrash.value = null;
if (!mounted) return;
_flow.showError(ConnectMode.host, CardError('Server crashed', crash));
setState(() => _view = HomeView.host);
});
});
}
@override
void dispose() {
_stopKickWatch();
_stopCrashWatch();
_flow.dispose();
_join.dispose();
_host.dispose();
super.dispose();
}
Future<void> _autoCheck() async {
@@ -63,40 +100,69 @@ class _HomeShellState extends State<HomeShell> {
if (s.autoCheckUpdates.peek()) await runUpdateCheck(widget.updates);
}
Future<void> _showKickedDialog(String reason) async {
if (!mounted || _dialogOpen || reason == _lastKickShown) return;
_lastKickShown = reason;
_dialogOpen = true;
try {
await showDialog<void>(
context: context,
builder: (context) => AlertDialog(
title: const Text('You were removed'),
content: Text(reason.isEmpty ? 'The host removed you from the server.' : reason),
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('OK'))],
),
);
} finally {
_dialogOpen = false;
}
}
void _select(HomeView v) => setState(() => _view = v);
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Row(
children: [
NavigationRail(
selectedIndex: _tab,
onDestinationSelected: (i) => setState(() => _tab = i),
labelType: NavigationRailLabelType.none,
minWidth: 64,
backgroundColor: scheme.surfaceContainerLowest,
indicatorShape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(kRadius)),
destinations: [
for (final (icon, selected, label) in _destinations)
NavigationRailDestination(
icon: Tooltip(message: label, child: Icon(icon)),
selectedIcon: Tooltip(message: label, child: Icon(selected)),
label: Text(label),
),
// Only the visible view is mounted: the Join tab polls /health only
// while shown. Form state lives here, so nothing is lost on switch.
final body = switch (_view) {
HomeView.join =>
JoinTab(flow: _flow, fields: _join, healthGet: widget.healthGet),
HomeView.host => HostTab(
flow: _flow,
fields: _host,
onOpenIntegrations: () => _select(HomeView.integrations),
),
HomeView.settings => SettingsPage(updates: widget.updates),
HomeView.integrations => const IntegrationsPage(),
HomeView.about => AboutPage(updates: widget.updates),
};
return CallbackShortcuts(
bindings: {
const SingleActivator(LogicalKeyboardKey.digit1, control: true): () =>
_select(HomeView.join),
const SingleActivator(LogicalKeyboardKey.digit2, control: true): () =>
_select(HomeView.host),
const SingleActivator(LogicalKeyboardKey.comma, control: true): () =>
_select(HomeView.settings),
},
child: FocusScope(
autofocus: true,
child: Column(
children: [
HomeTopBar(
view: _view,
onSelect: _select,
updates: widget.updates,
windowControls: widget.windowControls,
),
Divider(height: 1, thickness: 1, color: scheme.outlineVariant.withValues(alpha: 0.4)),
Expanded(
child: KeyedSubtree(key: ValueKey(_view), child: body),
),
],
),
Expanded(
child: IndexedStack(
index: _tab,
children: [
HomePage(updates: widget.updates),
SettingsPage(updates: widget.updates),
const IntegrationsPage(),
AboutPage(updates: widget.updates),
],
),
),
],
),
);
}
}
@@ -0,0 +1,235 @@
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import '../../state/session_state.dart';
import 'connect_flow.dart';
import 'connect_status.dart';
import 'media_picker.dart';
import 'ui.dart';
/// Host form state, owned by the Home shell so it survives tab switches.
class HostFields {
HostFields(ConnectForm f)
: port = TextEditingController(text: f.hostPort),
secret = TextEditingController(text: f.hostSecret),
media = MediaPickerController(mediaPath: f.mediaPath, linkUrl: f.linkUrl);
final TextEditingController port;
final TextEditingController secret;
final MediaPickerController media;
void dispose() {
port.dispose();
secret.dispose();
media.dispose();
}
}
/// Below this width the server column moves under the media picker.
const hostStackBelow = 860.0;
const _columnWidth = 340.0;
/// Media picker as the main area; port/password/Host in a narrow column.
class HostTab extends StatelessWidget {
final ConnectFlow flow;
final HostFields fields;
final VoidCallback? onOpenIntegrations;
const HostTab(
{super.key, required this.flow, required this.fields, this.onOpenIntegrations});
void _host() => flow.submit(ConnectMode.host, (f) {
final media = fields.media.selection;
f
..hostPort = fields.port.text.trim()
..hostSecret = fields.secret.text
..mediaPath = media.mediaPath
..linkUrl = media.linkUrl;
});
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return ListenableBuilder(
listenable: flow,
builder: (context, _) {
final busy = flow.busy == ConnectMode.host;
final picker = Column(
key: const ValueKey('host-picker'),
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('What to watch', style: t.titleMedium),
const SizedBox(height: 12),
MediaPicker(
controller: fields.media,
enabled: !busy,
autofocus: true,
onSubmit: _host,
onOpenIntegrations: onOpenIntegrations,
),
],
);
final column = _ServerColumn(flow: flow, fields: fields, onHost: _host);
return LayoutBuilder(builder: (context, box) {
final stacked = box.maxWidth < hostStackBelow;
return SingleChildScrollView(
padding: kPagePadding,
child: stacked
? Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
picker,
const SizedBox(height: 24),
Align(
alignment: Alignment.topLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: kFieldMaxWidth),
child: column,
),
),
],
)
: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: picker),
const SizedBox(width: 24),
SizedBox(width: _columnWidth, child: column),
],
),
);
});
},
);
}
}
class _ServerColumn extends StatelessWidget {
final ConnectFlow flow;
final HostFields fields;
final VoidCallback onHost;
const _ServerColumn({required this.flow, required this.fields, required this.onHost});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final t = theme.textTheme;
return Panel(
key: const ValueKey('host-column'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(Icons.cast, size: 20, color: theme.colorScheme.primary),
const SizedBox(width: 10),
Text('Your server', style: t.titleMedium),
],
),
const SizedBox(height: 4),
Text('Runs on this computer. Friends need this port reachable.',
style: t.bodySmall?.copyWith(color: Colors.grey)),
const SizedBox(height: kGap),
AbsorbPointer(
absorbing: flow.busy == ConnectMode.host,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 12,
children: [
Align(
alignment: Alignment.centerLeft,
child: SizedBox(
width: 120,
child: TextField(
key: const ValueKey('host-port'),
controller: fields.port,
decoration: fieldDecoration('Port'),
keyboardType: TextInputType.number,
onChanged: (_) => flow.clearError(ConnectMode.host),
onSubmitted: (_) => onHost(),
),
),
),
ListenableBuilder(
listenable: fields.secret,
builder: (context, _) => TextField(
key: const ValueKey('host-password'),
controller: fields.secret,
obscureText: true,
decoration: fieldDecoration('Password').copyWith(
helperText:
fields.secret.text.isEmpty ? 'No password — anyone can join' : null,
),
onSubmitted: (_) => onHost(),
),
),
],
),
),
ListenableBuilder(
listenable: fields.media,
builder: (context, _) => Padding(
padding: const EdgeInsets.only(top: 12),
child: Text(_mediaSummary(fields.media.selection),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: t.bodySmall?.copyWith(color: Colors.grey)),
),
),
ConnectFeedback(flow: flow, mode: ConnectMode.host),
const SizedBox(height: kGap),
ConnectButton(
flow: flow, mode: ConnectMode.host, label: 'Host', onSubmit: onHost, expand: true),
_HostedRecents(fields: fields, enabled: flow.busy == null),
],
),
);
}
static String _mediaSummary(MediaSelection s) {
final raw = s.linkUrl.isNotEmpty ? s.linkUrl : s.mediaPath;
if (raw.isEmpty) return 'No media selected (optional).';
final name = raw.split(RegExp(r'[/\\]')).lastWhere((p) => p.isNotEmpty, orElse: () => raw);
return 'Playing: $name';
}
}
/// Ports hosted before: one click refills port and password.
class _HostedRecents extends StatelessWidget {
final HostFields fields;
final bool enabled;
const _HostedRecents({required this.fields, required this.enabled});
@override
Widget build(BuildContext context) {
return SignalBuilder(builder: (context) {
final list = [
for (final r in SessionState.instance.recents.value)
if (r.hosted) r
];
if (list.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: kGap),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final r in list)
InputChip(
key: ValueKey('recent-${r.key}'),
avatar: Icon(r.secret.isEmpty ? Icons.history : Icons.lock_outline, size: 16),
label: Text('Port ${r.port}'),
tooltip: 'Use port ${r.port} again',
isEnabled: enabled,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
onPressed: () {
fields.port.text = r.port;
fields.secret.text = r.secret;
},
deleteButtonTooltipMessage: 'Forget',
onDeleted: () => SessionState.instance.forgetServer(r),
),
],
),
);
});
}
}
@@ -0,0 +1,388 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
import 'package:sync_relay_core/core.dart';
import '../../state/session_state.dart';
import 'connect_flow.dart';
import 'connect_status.dart';
import 'health_probe.dart';
import 'ui.dart';
/// Join form text, owned by the Home shell so it survives tab switches.
class JoinFields {
JoinFields(ConnectForm f)
: host = TextEditingController(text: f.host),
port = TextEditingController(text: f.port),
secret = TextEditingController(text: f.secret);
final TextEditingController host;
final TextEditingController port;
final TextEditingController secret;
void dispose() {
host.dispose();
port.dispose();
secret.dispose();
}
}
/// Smallest recent-server tile; the grid adds columns as the window widens.
const _minTileWidth = 300.0;
/// One compact address/port/password row, then recent servers with live
/// status, as a grid that fills the width.
class JoinTab extends StatelessWidget {
final ConnectFlow flow;
final JoinFields fields;
final HealthGetter healthGet;
const JoinTab(
{super.key, required this.flow, required this.fields, required this.healthGet});
void _join() => flow.submit(ConnectMode.join, (f) {
f
..host = fields.host.text
..port = fields.port.text.trim()
..secret = fields.secret.text;
});
void _joinRecent(RecentServer r) {
fields.host.text = r.host;
fields.port.text = r.port;
fields.secret.text = r.secret;
_join();
}
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return ListenableBuilder(
listenable: flow,
builder: (context, _) => SingleChildScrollView(
padding: kPagePadding,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Join a friend’s server', style: t.titleMedium),
const SizedBox(height: 12),
AbsorbPointer(
absorbing: flow.busy == ConnectMode.join,
child: _joinRow(),
),
Align(
alignment: Alignment.centerLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: kFieldMaxWidth),
child: ConnectFeedback(flow: flow, mode: ConnectMode.join),
),
),
const SizedBox(height: 32),
Text('Recent servers', style: t.titleMedium),
const SizedBox(height: 12),
SignalBuilder(builder: (context) {
final list = [
for (final r in SessionState.instance.recents.value)
if (!r.hosted) r
];
return RecentServersGrid(
servers: list,
enabled: flow.busy == null,
healthGet: healthGet,
onJoin: _joinRecent,
onForget: SessionState.instance.forgetServer,
);
}),
],
),
),
);
}
Widget _joinRow() {
return Wrap(
key: const ValueKey('join-row'),
spacing: 12,
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 280,
child: TextField(
key: const ValueKey('join-address'),
controller: fields.host,
decoration: fieldDecoration('Address', hint: 'IP or hostname'),
onChanged: (_) => flow.clearError(ConnectMode.join),
onSubmitted: (_) => _join(),
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 6),
child: Text(':', style: TextStyle(fontSize: 18, color: Colors.grey)),
),
SizedBox(
width: 96,
child: TextField(
key: const ValueKey('join-port'),
controller: fields.port,
decoration: fieldDecoration('Port'),
keyboardType: TextInputType.number,
onSubmitted: (_) => _join(),
),
),
],
),
SizedBox(
width: 220,
child: TextField(
key: const ValueKey('join-password'),
controller: fields.secret,
obscureText: true,
decoration: fieldDecoration('Password', hint: 'If the server has one'),
onSubmitted: (_) => _join(),
),
),
ConnectButton(flow: flow, mode: ConnectMode.join, label: 'Join', onSubmit: _join),
],
);
}
}
/// Recent servers as tiles; polls each one's `/health` every
/// [healthPollEvery] while mounted (i.e. while the Join tab is visible).
class RecentServersGrid extends StatefulWidget {
final List<RecentServer> servers;
final bool enabled;
final HealthGetter healthGet;
final ValueChanged<RecentServer> onJoin;
final ValueChanged<RecentServer> onForget;
const RecentServersGrid({
super.key,
required this.servers,
required this.enabled,
required this.healthGet,
required this.onJoin,
required this.onForget,
});
@override
State<RecentServersGrid> createState() => _RecentServersGridState();
}
class _RecentServersGridState extends State<RecentServersGrid> {
final _health = <String, ServerHealth>{};
final _inFlight = <String>{};
Timer? _timer;
@override
void initState() {
super.initState();
_pollAll();
_timer = Timer.periodic(healthPollEvery, (_) => _pollAll());
}
@override
void didUpdateWidget(RecentServersGrid old) {
super.didUpdateWidget(old);
for (final r in widget.servers) {
if (!_health.containsKey(r.key)) _poll(r);
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
void _pollAll() {
for (final r in widget.servers) {
_poll(r);
}
}
Future<void> _poll(RecentServer r) async {
if (!_inFlight.add(r.key)) return;
try {
final h = await probeHealth(r.host, r.port, widget.healthGet);
if (mounted) setState(() => _health[r.key] = h);
} finally {
_inFlight.remove(r.key);
}
}
@override
Widget build(BuildContext context) {
if (widget.servers.isEmpty) return const _EmptyRecents();
return LayoutBuilder(builder: (context, box) {
final cols = ((box.maxWidth + kGap) / (_minTileWidth + kGap)).floor().clamp(1, 64);
final width = (box.maxWidth - kGap * (cols - 1)) / cols;
return Wrap(
spacing: kGap,
runSpacing: kGap,
children: [
for (final r in widget.servers)
SizedBox(
width: width,
child: _RecentTile(
key: ValueKey('recent-${r.key}'),
server: r,
health: _health[r.key],
enabled: widget.enabled,
onJoin: () => widget.onJoin(r),
onForget: () => widget.onForget(r),
),
),
],
);
});
}
}
class _RecentTile extends StatelessWidget {
final RecentServer server;
/// Null until the first probe answers.
final ServerHealth? health;
final bool enabled;
final VoidCallback onJoin;
final VoidCallback onForget;
const _RecentTile({
super.key,
required this.server,
required this.health,
required this.enabled,
required this.onJoin,
required this.onForget,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final scheme = theme.colorScheme;
final t = theme.textTheme;
final h = health;
final address = '${server.host}:${server.port}';
final title = h != null && h.name.isNotEmpty ? h.name : server.host;
final (status, statusColor) = switch (h) {
null => ('Checking…', Colors.grey),
ServerHealth(online: true) => (
'● online · ${h.viewers} watching',
Colors.greenAccent.shade400
),
_ => ('○ offline', Colors.grey),
};
return Material(
color: scheme.surfaceContainer,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(kRadius),
side: BorderSide(color: scheme.outlineVariant.withValues(alpha: 0.5)),
),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: enabled ? onJoin : null,
child: Tooltip(
message: 'Join $address',
waitDuration: const Duration(milliseconds: 600),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 6, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.dns_outlined, size: 20, color: scheme.primary),
const SizedBox(width: 10),
Expanded(
child: Text(title,
maxLines: 1, overflow: TextOverflow.ellipsis, style: t.titleMedium),
),
if (server.secret.isNotEmpty)
const Tooltip(
message: 'Password saved',
child: Icon(Icons.lock_outline, size: 16, color: Colors.grey),
),
IconButton(
tooltip: 'Forget',
visualDensity: VisualDensity.compact,
iconSize: 18,
onPressed: onForget,
icon: const Icon(Icons.close),
),
],
),
Padding(
padding: const EdgeInsets.only(left: 30, right: 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(address,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: t.bodySmall?.copyWith(color: Colors.grey)),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Text(status,
key: const ValueKey('recent-status'),
style: t.bodyMedium?.copyWith(color: statusColor)),
),
if (h != null && h.needsUpdate)
Tooltip(
message: 'Server protocol ${h.proto}, this app '
'${RelayConstants.protoVersion} — update so both match',
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: scheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text('needs update',
style: t.labelSmall
?.copyWith(color: scheme.onErrorContainer)),
),
),
],
),
],
),
),
],
),
),
),
),
);
}
}
class _EmptyRecents extends StatelessWidget {
const _EmptyRecents();
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Panel(
key: const ValueKey('recents-empty'),
child: Row(
children: [
const Icon(Icons.history, size: 28, color: Colors.grey),
const SizedBox(width: kGap),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('No recent servers yet', style: t.titleSmall),
const SizedBox(height: 2),
Text('Servers you join show up here with live status; click one to rejoin.',
style: t.bodySmall?.copyWith(color: Colors.grey)),
],
),
),
],
),
);
}
}
@@ -0,0 +1,352 @@
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../../widgets/media_input.dart';
import 'ui.dart';
const videoExtensions = ['mp4', 'mkv', 'webm', 'avi', 'mov', 'm4v', 'ts'];
enum MediaKind { jellyfin, link, file }
/// What to play: at most one of the two is non-empty; both empty = none.
typedef MediaSelection = ({String mediaPath, String linkUrl});
/// State of a [MediaPicker], owned by whoever hosts it (Home's Host tab,
/// later the session's swap panel) so it survives rebuilds.
class MediaPickerController extends ChangeNotifier {
MediaPickerController({String mediaPath = '', String linkUrl = '', MediaKind? source})
: link = TextEditingController(text: linkUrl),
file = TextEditingController(text: mediaPath),
_source = source ?? (linkUrl.isNotEmpty ? MediaKind.link : MediaKind.file) {
link.addListener(notifyListeners);
file.addListener(notifyListeners);
}
final TextEditingController link;
final TextEditingController file;
MediaKind _source;
String? _notice;
MediaKind get source => _source;
set source(MediaKind v) {
if (v == _source) return;
_source = v;
_notice = null;
notifyListeners();
}
/// One-line hint under the fields (e.g. the file dialog failed).
String? get notice => _notice;
set notice(String? v) {
_notice = v;
notifyListeners();
}
MediaSelection get selection => switch (_source) {
MediaKind.link => (mediaPath: '', linkUrl: link.text.trim()),
MediaKind.file => (mediaPath: file.text.trim(), linkUrl: ''),
MediaKind.jellyfin => (mediaPath: '', linkUrl: ''),
};
/// Drops pasted text where it belongs: http(s) into Link, else File.
void pasteText(String text) {
final parsed = parseMediaInput(text);
if (parsed.linkUrl.isNotEmpty) {
link.text = parsed.linkUrl;
source = MediaKind.link;
} else if (parsed.mediaPath.isNotEmpty) {
file.text = parsed.mediaPath;
source = MediaKind.file;
}
}
@override
void dispose() {
link.dispose();
file.dispose();
super.dispose();
}
}
/// Source segments (Jellyfin / Link / File) and the matching input.
/// Ctrl+V anywhere in the picker outside a text field pastes a link or path.
class MediaPicker extends StatefulWidget {
final MediaPickerController controller;
final ValueChanged<MediaSelection>? onChanged;
/// Enter in a field.
final VoidCallback? onSubmit;
final VoidCallback? onOpenIntegrations;
final bool enabled;
final bool autofocus;
const MediaPicker({
super.key,
required this.controller,
this.onChanged,
this.onSubmit,
this.onOpenIntegrations,
this.enabled = true,
this.autofocus = false,
});
@override
State<MediaPicker> createState() => _MediaPickerState();
}
class _MediaPickerState extends State<MediaPicker> {
MediaPickerController get _c => widget.controller;
@override
void initState() {
super.initState();
_c.addListener(_changed);
}
@override
void didUpdateWidget(MediaPicker old) {
super.didUpdateWidget(old);
if (old.controller != widget.controller) {
old.controller.removeListener(_changed);
widget.controller.addListener(_changed);
}
}
@override
void dispose() {
_c.removeListener(_changed);
super.dispose();
}
void _changed() {
widget.onChanged?.call(_c.selection);
if (mounted) setState(() {});
}
Future<String?> _clipboard() async {
try {
return (await Clipboard.getData(Clipboard.kTextPlain))?.text?.trim();
} catch (_) {
return null;
}
}
Future<void> _pasteAnywhere() async {
final text = await _clipboard();
if (text != null && text.isNotEmpty) _c.pasteText(text);
}
Future<void> _pasteLink() async {
final text = await _clipboard();
if (text != null && text.isNotEmpty) _c.link.text = text;
}
Future<void> _browse() async {
try {
final files = await FilePicker.pickFiles(
dialogTitle: 'Pick media file',
type: FileType.custom,
allowedExtensions: videoExtensions,
);
final path = files.isEmpty ? null : files.first.path;
if (path != null) {
_c.file.text = path;
_c.notice = null;
}
} catch (e) {
_c.notice = 'File dialog unavailable (${e.runtimeType}) — paste the path instead';
}
}
KeyEventResult _onKey(FocusNode node, KeyEvent e) {
final ctrl = HardwareKeyboard.instance.isControlPressed ||
HardwareKeyboard.instance.isMetaPressed;
if (!widget.enabled || e is! KeyDownEvent || !ctrl || e.logicalKey != LogicalKeyboardKey.keyV) {
return KeyEventResult.ignored;
}
// A focused text field handles its own paste.
final inField = FocusManager.instance.primaryFocus?.context
?.findAncestorStateOfType<EditableTextState>() !=
null;
if (inField) return KeyEventResult.ignored;
_pasteAnywhere();
return KeyEventResult.handled;
}
@override
Widget build(BuildContext context) {
final source = _c.source;
return Focus(
autofocus: widget.autofocus,
onKeyEvent: _onKey,
child: AbsorbPointer(
absorbing: !widget.enabled,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Align(
alignment: Alignment.centerLeft,
child: SegmentedButton<MediaKind>(
key: const ValueKey('media-source'),
showSelectedIcon: false,
segments: const [
ButtonSegment(
value: MediaKind.jellyfin,
icon: Icon(Icons.video_library_outlined),
label: Text('Jellyfin')),
ButtonSegment(
value: MediaKind.link, icon: Icon(Icons.link), label: Text('Link')),
ButtonSegment(
value: MediaKind.file,
icon: Icon(Icons.insert_drive_file_outlined),
label: Text('File')),
],
selected: {source},
onSelectionChanged: (s) => _c.source = s.first,
),
),
const SizedBox(height: kGap),
switch (source) {
MediaKind.jellyfin =>
_JellyfinPlaceholder(onOpenIntegrations: widget.onOpenIntegrations),
MediaKind.link => _FieldRow(
key: const ValueKey('media-link'),
field: TextField(
controller: _c.link,
autofocus: widget.autofocus,
decoration: fieldDecoration('Video link', hint: 'https://…/movie.mkv'),
keyboardType: TextInputType.url,
onSubmitted: (_) => widget.onSubmit?.call(),
),
button: OutlinedButton.icon(
onPressed: _pasteLink,
icon: const Icon(Icons.content_paste, size: 18),
label: const Text('Paste'),
),
help: 'A direct http(s) link to a video file; everyone streams it from there.',
notice: _c.notice,
),
MediaKind.file => _FieldRow(
key: const ValueKey('media-file'),
field: TextField(
controller: _c.file,
decoration: fieldDecoration('Video file', hint: 'Path to a video on this computer'),
onSubmitted: (_) => widget.onSubmit?.call(),
),
button: OutlinedButton.icon(
onPressed: _browse,
icon: const Icon(Icons.folder_open, size: 18),
label: const Text('Browse'),
),
help: 'Friends stream it from you while you watch. Optional — you can host without media.',
notice: _c.notice,
),
},
],
),
),
);
}
}
class _FieldRow extends StatelessWidget {
final Widget field;
final Widget button;
final String help;
final String? notice;
const _FieldRow(
{super.key, required this.field, required this.button, required this.help, this.notice});
@override
Widget build(BuildContext context) {
final small = Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey);
return Align(
alignment: Alignment.topLeft,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: kFieldMaxWidth + 140),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(child: field),
const SizedBox(width: 12),
button,
],
),
const SizedBox(height: 8),
Text(notice ?? help, style: small),
],
),
),
);
}
}
/// Where the Jellyfin library grid will go: ghost posters that fill the
/// width, dimmed, with the "coming soon" note on top.
class _JellyfinPlaceholder extends StatelessWidget {
final VoidCallback? onOpenIntegrations;
const _JellyfinPlaceholder({this.onOpenIntegrations});
static const _posterWidth = 140.0;
static const _rows = 2;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final t = Theme.of(context).textTheme;
return LayoutBuilder(builder: (context, box) {
final cols = ((box.maxWidth + 12) / (_posterWidth + 12)).floor().clamp(1, 40);
return Stack(
key: const ValueKey('media-jellyfin'),
alignment: Alignment.center,
children: [
Opacity(
opacity: 0.35,
child: Column(
spacing: 12,
children: [
for (var r = 0; r < _rows; r++)
Row(
spacing: 12,
children: [
for (var c = 0; c < cols; c++)
Expanded(
child: AspectRatio(
aspectRatio: 2 / 3,
child: DecoratedBox(
decoration: BoxDecoration(
color: scheme.surfaceContainerHigh,
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
],
),
),
Panel(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.video_library_outlined, size: 32, color: scheme.primary),
const SizedBox(height: 8),
Text('Link your Jellyfin in Integrations — coming soon',
textAlign: TextAlign.center, style: t.titleSmall),
if (onOpenIntegrations != null) ...[
const SizedBox(height: 8),
TextButton(
onPressed: onOpenIntegrations, child: const Text('Open Integrations')),
],
],
),
),
],
);
});
}
}
+38 -23
View File
@@ -229,7 +229,11 @@ Future<void> showProfileDialog(BuildContext context) {
/// Home's top-left: avatar + name, click to edit.
class ProfileChip extends StatelessWidget {
const ProfileChip({super.key});
/// One line and a small avatar, for the Home top bar.
final bool dense;
/// Just the avatar, for very narrow windows.
final bool avatarOnly;
const ProfileChip({super.key, this.dense = false, this.avatarOnly = false});
@override
Widget build(BuildContext context) {
@@ -239,31 +243,42 @@ class ProfileChip extends StatelessWidget {
child: InkWell(
borderRadius: BorderRadius.circular(kRadius),
onTap: () => showProfileDialog(context),
child: Padding(
padding: const EdgeInsets.fromLTRB(6, 6, 14, 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const ProfileAvatar(radius: 20),
const SizedBox(width: 12),
SignalBuilder(builder: (context) {
final n = p.name.value;
final t = Theme.of(context).textTheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
child: avatarOnly
? const Padding(padding: EdgeInsets.all(4), child: ProfileAvatar(radius: 14))
: Padding(
padding: dense
? const EdgeInsets.fromLTRB(4, 4, 10, 4)
: const EdgeInsets.fromLTRB(6, 6, 14, 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(n.isEmpty ? 'Set your name' : n,
style: t.titleMedium?.copyWith(
color: n.isEmpty ? Colors.grey : null)),
Text('Edit profile',
style: t.bodySmall?.copyWith(color: Colors.grey)),
ProfileAvatar(radius: dense ? 14 : 20),
SizedBox(width: dense ? 8 : 12),
SignalBuilder(builder: (context) {
final n = p.name.value;
final t = Theme.of(context).textTheme;
final name = Text(n.isEmpty ? 'Set your name' : n,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: (dense ? t.titleSmall : t.titleMedium)
?.copyWith(color: n.isEmpty ? Colors.grey : null));
if (dense) {
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 200), child: name);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
name,
Text('Edit profile',
style: t.bodySmall?.copyWith(color: Colors.grey)),
],
);
}),
],
);
}),
],
),
),
),
),
),
);
}
@@ -0,0 +1,151 @@
import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';
import '../../update/update_controller.dart';
import '../../widgets/window_buttons.dart';
import 'profile.dart';
import 'update_indicator.dart';
/// What the Home body shows: the two tabs, or one of the side pages.
enum HomeView { join, host, settings, integrations, about }
const homeTopBarHeight = 48.0;
/// Below this width the bar drops the profile name and tab icons.
const _compactBelow = 760.0;
/// Home's single top bar: profile, Join/Host tabs, update pill, page
/// buttons and (in the real window) the drag area and window buttons.
class HomeTopBar extends StatelessWidget {
final HomeView view;
final ValueChanged<HomeView> onSelect;
final UpdateController? updates;
/// Off in tests: the drag area and window buttons need window_manager.
final bool windowControls;
const HomeTopBar({
super.key,
required this.view,
required this.onSelect,
this.updates,
this.windowControls = false,
});
static const _pages = [
(HomeView.settings, Icons.settings_outlined, Icons.settings, 'Settings'),
(HomeView.integrations, Icons.extension_outlined, Icons.extension, 'Integrations'),
(HomeView.about, Icons.info_outline, Icons.info, 'About'),
];
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, box) => _bar(context, compact: box.maxWidth < _compactBelow));
}
Widget _bar(BuildContext context, {required bool compact}) {
final scheme = Theme.of(context).colorScheme;
final row = Row(
children: [
const SizedBox(width: 12),
ProfileChip(dense: true, avatarOnly: compact),
SizedBox(width: compact ? 8 : 20),
_Tab(
key: const ValueKey('tab-join'),
label: 'Join',
icon: compact ? null : Icons.login,
selected: view == HomeView.join,
onTap: () => onSelect(HomeView.join),
),
_Tab(
key: const ValueKey('tab-host'),
label: 'Host',
icon: compact ? null : Icons.cast,
selected: view == HomeView.host,
onTap: () => onSelect(HomeView.host),
),
const Spacer(),
UpdateIndicator(controller: updates),
const SizedBox(width: 8),
for (final (v, icon, selectedIcon, label) in _pages)
IconButton(
tooltip: label,
isSelected: view == v,
icon: Icon(icon, size: 20),
selectedIcon: Icon(selectedIcon, size: 20, color: scheme.primary),
onPressed: () => onSelect(v),
),
if (windowControls) ...[
const SizedBox(width: 4),
SizedBox(
height: 20,
child: VerticalDivider(width: 12, color: scheme.outlineVariant),
),
const WindowButtons(),
] else
const SizedBox(width: 8),
],
);
final bar = SizedBox(height: homeTopBarHeight, child: row);
return ColoredBox(
color: scheme.surfaceContainerLowest,
// Empty bar space drags the window; the controls on top take their
// own clicks without waiting out the drag area's double-tap.
child: windowControls
? Stack(children: [
const Positioned.fill(child: DragToMoveArea(child: SizedBox.expand())),
bar,
])
: bar,
);
}
}
class _Tab extends StatelessWidget {
final String label;
final IconData? icon;
final bool selected;
final VoidCallback onTap;
const _Tab(
{super.key,
required this.label,
this.icon,
required this.selected,
required this.onTap});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final color = selected ? scheme.primary : scheme.onSurfaceVariant;
return Semantics(
selected: selected,
button: true,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(8),
child: Container(
height: homeTopBarHeight,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: selected ? scheme.primary : Colors.transparent, width: 2),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 18, color: color),
const SizedBox(width: 8),
],
Text(label,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: color, fontWeight: selected ? FontWeight.w600 : null)),
],
),
),
),
);
}
}
+40
View File
@@ -121,3 +121,43 @@ class PageScroll extends StatelessWidget {
);
}
}
/// Widest a single text field gets, however wide the window.
const kFieldMaxWidth = 720.0;
/// Inline failure under a connect form: bold title, selectable message.
class ErrorBox extends StatelessWidget {
final String title;
final String message;
const ErrorBox({super.key, required this.title, required this.message});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: scheme.errorContainer.withValues(alpha: 0.35),
borderRadius: BorderRadius.circular(8),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.error_outline, size: 18, color: scheme.error),
const SizedBox(width: 8),
Expanded(
child: SelectableText.rich(
TextSpan(children: [
TextSpan(
text: '$title\n',
style: const TextStyle(fontWeight: FontWeight.w600)),
TextSpan(text: message),
]),
style: const TextStyle(fontSize: 13),
),
),
],
),
);
}
}