- Add responsive breakpoints (compact <600, medium 600-1023, expanded >=1024) - Add ViewportInfo InheritedWidget for layout-aware descendants - Add inline ChatPanel (380dp right column) for expanded desktop layout - Add channel right-click context menu with Chat option for in-place switching - Add per-target draft persistence via restoredDraft/onDraftChanged callbacks - Fix header chat button to switch to current voice channel when panel open - Fix close = dismiss (preserves last target and draft for reopen) - Add unread dot indicator on channel tiles when chat is closed - Fix audio regression: decimate dBFS computation to every 3rd callback (~31 Hz) to avoid buffer underruns on macOS CoreAudio real-time thread - Add tools/build-macos.sh release build script (7-step process) - Add chat panel switching implementation plan and 3-panel design spec Tests: 183 passed, 2 skipped. Flutter analyze clean.
214 lines
6.4 KiB
Dart
214 lines
6.4 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import '../design/breakpoints.dart';
|
|
import '../l10n/generated/app_localizations.dart';
|
|
import '../src/rust/api.dart' as rust;
|
|
|
|
/// Server connection form.
|
|
class ConnectForm extends StatefulWidget {
|
|
/// Construct a connect form.
|
|
const ConnectForm({
|
|
super.key,
|
|
required this.hostCtl,
|
|
required this.nickCtl,
|
|
required this.passwordCtl,
|
|
required this.onConnect,
|
|
required this.onAddBookmark,
|
|
});
|
|
|
|
/// Server host controller.
|
|
final TextEditingController hostCtl;
|
|
|
|
/// Nickname controller.
|
|
final TextEditingController nickCtl;
|
|
|
|
/// Server password controller.
|
|
final TextEditingController passwordCtl;
|
|
|
|
/// Called when the user submits a connection.
|
|
final VoidCallback onConnect;
|
|
|
|
/// Called when the user saves the current form as a bookmark.
|
|
final VoidCallback onAddBookmark;
|
|
|
|
@override
|
|
State<ConnectForm> createState() => _ConnectFormState();
|
|
}
|
|
|
|
class _ConnectFormState extends State<ConnectForm> {
|
|
final FocusNode _hostFocus = FocusNode();
|
|
final FocusNode _nickFocus = FocusNode();
|
|
final FocusNode _passwordFocus = FocusNode();
|
|
|
|
void _onTapOutside(PointerDownEvent _) {
|
|
FocusManager.instance.primaryFocus?.unfocus();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_hostFocus.dispose();
|
|
_nickFocus.dispose();
|
|
_passwordFocus.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppL10n.of(context);
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
TextField(
|
|
controller: widget.hostCtl,
|
|
focusNode: _hostFocus,
|
|
onTapOutside: _onTapOutside,
|
|
keyboardType: TextInputType.url,
|
|
textCapitalization: TextCapitalization.none,
|
|
textInputAction: TextInputAction.next,
|
|
autocorrect: false,
|
|
enableSuggestions: false,
|
|
inputFormatters: [
|
|
FilteringTextInputFormatter.deny(RegExp(r'\s')),
|
|
TextInputFormatter.withFunction(
|
|
(oldValue, newValue) => newValue.copyWith(
|
|
text: newValue.text.toLowerCase(),
|
|
selection: newValue.selection,
|
|
),
|
|
),
|
|
],
|
|
decoration: InputDecoration(
|
|
labelText: l10n.fieldServerHost,
|
|
hintText: 'host[:port]',
|
|
prefixIcon: const Icon(Icons.dns_outlined),
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
TextField(
|
|
controller: widget.nickCtl,
|
|
focusNode: _nickFocus,
|
|
onTapOutside: _onTapOutside,
|
|
textInputAction: TextInputAction.next,
|
|
autocorrect: false,
|
|
enableSuggestions: false,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.fieldNickname,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
TextField(
|
|
controller: widget.passwordCtl,
|
|
focusNode: _passwordFocus,
|
|
onTapOutside: _onTapOutside,
|
|
obscureText: true,
|
|
textInputAction: TextInputAction.done,
|
|
autocorrect: false,
|
|
enableSuggestions: false,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.fieldServerPassword,
|
|
helperText: l10n.fieldServerPasswordHelp,
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final connectButton = FilledButton.icon(
|
|
icon: const Icon(Icons.login),
|
|
label: Text(l10n.connectAction),
|
|
onPressed: widget.onConnect,
|
|
);
|
|
final bookmarkButton = OutlinedButton.icon(
|
|
icon: const Icon(Icons.bookmark_add_outlined),
|
|
label: Text(l10n.bookmarkAddAction),
|
|
onPressed: widget.onAddBookmark,
|
|
);
|
|
if (constraints.maxWidth <=
|
|
ChanoraBreakpoints.connectActionsStackMaxWidth) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
connectButton,
|
|
const SizedBox(height: 8),
|
|
bookmarkButton,
|
|
],
|
|
);
|
|
}
|
|
return Row(
|
|
children: [
|
|
Expanded(child: connectButton),
|
|
const SizedBox(width: 8),
|
|
Flexible(child: bookmarkButton),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Saved bookmark list.
|
|
class BookmarkList extends StatelessWidget {
|
|
/// Construct a bookmark list.
|
|
const BookmarkList({
|
|
super.key,
|
|
required this.bookmarks,
|
|
required this.onConnect,
|
|
required this.onDelete,
|
|
});
|
|
|
|
/// Saved bookmarks.
|
|
final List<rust.BridgeBookmark> bookmarks;
|
|
|
|
/// Connect to a bookmark.
|
|
final ValueChanged<rust.BridgeBookmark> onConnect;
|
|
|
|
/// Delete a bookmark.
|
|
final ValueChanged<rust.BridgeBookmark> onDelete;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = AppL10n.of(context);
|
|
final theme = Theme.of(context);
|
|
if (bookmarks.isEmpty) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Text(l10n.bookmarksEmpty, style: theme.textTheme.bodySmall),
|
|
);
|
|
}
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Text(l10n.bookmarksHeading, style: theme.textTheme.titleSmall),
|
|
const SizedBox(height: 4),
|
|
for (final b in bookmarks)
|
|
Card(
|
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
|
child: ListTile(
|
|
title: Text(b.displayName),
|
|
subtitle: Text('${b.host} — ${b.nickname}'),
|
|
trailing: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
IconButton(
|
|
icon: const Icon(Icons.login),
|
|
tooltip: l10n.connectAction,
|
|
onPressed: () => onConnect(b),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.delete_outline),
|
|
tooltip: l10n.bookmarkDeleteAction,
|
|
onPressed: () => onDelete(b),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|