Files
Edison Jwa 5e8b7915db feat(ui): adaptive 3-panel layout, chat panel switching, audio metering fix
- 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.
2026-06-07 23:12:07 +09:00

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),
),
],
),
),
),
],
);
}
}