feat: modern macOS window chrome + Linux build script
macOS: - Transparent title bar with hidden title, full-size content view - macOS: inline Row header (no AppBar) with 56px traffic-light pad - Other platforms: standard Material AppBar unchanged - App name 'Chanora' in CFBundleName/CFBundleDisplayName (iOS + macOS) - NSLocalNetworkUsageDescription added to both platforms Linux: - tools/build-linux.sh: builds Rust .so + Flutter bundle + tarball - Verifies GTK3, libopus dev headers, Rust target - Copies libchanora_bridge.so into bundle/lib/
This commit is contained in:
@@ -7,7 +7,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Chanora Flutter</string>
|
||||
<string>Chanora</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -15,7 +15,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>chanora_flutter</string>
|
||||
<string>Chanora</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
|
||||
+243
-269
@@ -27,6 +27,11 @@ import 'widgets/voice_bar.dart';
|
||||
import 'widgets/voice_compact.dart';
|
||||
import 'widgets/voice_settings.dart';
|
||||
|
||||
bool get _isMacOS => !kIsWeb && Platform.isMacOS;
|
||||
|
||||
/// Top padding for macOS to clear traffic-light buttons.
|
||||
const double _macOSTrafficLightPad = 56.0;
|
||||
|
||||
/// True when the host is a touch-only mobile platform without a
|
||||
/// hardware keyboard. Mirrors the helpers in widgets/voice_bar.dart
|
||||
/// and widgets/voice_settings.dart so the AppBar + narrow-mode
|
||||
@@ -1007,280 +1012,249 @@ class _BetaHomeState extends State<_BetaHome> {
|
||||
}
|
||||
}
|
||||
|
||||
final headerActions = [
|
||||
if (_phase == _Phase.connected &&
|
||||
_inChannel &&
|
||||
MediaQuery.of(context).size.width < 840.0) ...[
|
||||
IconButton(
|
||||
tooltip: l10n.voiceHardMuteLabel,
|
||||
icon: Icon(_hardMute ? Icons.mic_off : Icons.mic),
|
||||
isSelected: _hardMute,
|
||||
selectedIcon: const Icon(Icons.mic_off),
|
||||
onPressed: _onToggleHardMute,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.voiceOutputMuteLabel,
|
||||
icon: Icon(_outputMuted ? Icons.headset_off : Icons.headset),
|
||||
isSelected: _outputMuted,
|
||||
selectedIcon: const Icon(Icons.headset_off),
|
||||
onPressed: _toggleOutputMute,
|
||||
),
|
||||
],
|
||||
IconButton(
|
||||
tooltip: l10n.aboutAction,
|
||||
icon: const Icon(Icons.info_outline),
|
||||
onPressed: () => _onShowAbout(context),
|
||||
),
|
||||
if (_phase == _Phase.connected) ...[
|
||||
IconButton(
|
||||
tooltip: l10n.disconnectAction,
|
||||
icon: const Icon(Icons.logout),
|
||||
onPressed: _onDisconnect,
|
||||
),
|
||||
],
|
||||
];
|
||||
|
||||
final headerTitle = _AppBarTitle(
|
||||
phase: _phase,
|
||||
inChannel: _inChannel,
|
||||
channelName: _currentVoiceChannelName(),
|
||||
);
|
||||
|
||||
final bodyContent = LayoutBuilder(
|
||||
builder: (ctx, bodyConstraints) {
|
||||
const wideBreakpoint = 840.0;
|
||||
final isWideSnapshot =
|
||||
bodyConstraints.maxWidth >= wideBreakpoint &&
|
||||
_phase == _Phase.connected &&
|
||||
_snapshot != null;
|
||||
final banner = Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
l10n.homeNotProductionReadyBanner,
|
||||
style: TextStyle(color: theme.colorScheme.onTertiaryContainer),
|
||||
),
|
||||
);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)],
|
||||
Text(statusText(), style: theme.textTheme.titleMedium),
|
||||
if (_lostReason != null || _reconnectAttempt != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_reconnectAttempt != null
|
||||
? l10n.statusReconnecting(
|
||||
_reconnectAttempt!,
|
||||
_reconnectDelay ?? 0,
|
||||
)
|
||||
: l10n.statusConnectionLost(_lostReason ?? ''),
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
if (_phase == _Phase.idle) ...[
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_ConnectForm(
|
||||
hostCtl: _hostCtl,
|
||||
nickCtl: _nickCtl,
|
||||
passwordCtl: _passwordCtl,
|
||||
onConnect: () => _onConnect(),
|
||||
onAddBookmark: _onAddCurrentBookmark,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_BookmarkList(
|
||||
bookmarks: _bookmarks,
|
||||
onConnect: _onUseBookmark,
|
||||
onDelete: _onDeleteBookmark,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
] else if (_phase == _Phase.connecting) ...[
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
] else if (_phase == _Phase.connected && _snapshot != null) ...[
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (ctx, constraints) {
|
||||
final voiceBar = VoiceBar(
|
||||
inChannel: _inChannel,
|
||||
transmitMode: _transmitMode,
|
||||
hardMute: _hardMute,
|
||||
outputMuted: _outputMuted,
|
||||
releaseTailMs: _releaseTailMs,
|
||||
channelName: _currentVoiceChannelName(),
|
||||
audioStats: _audioStats,
|
||||
pttLevel: _pttLevel,
|
||||
pttBackendId: _pttBackendId,
|
||||
pttBoundInputClass: _pttBoundInputClass,
|
||||
pttBoundKeyLabel: _pttBoundKeyLabel,
|
||||
onToggleMute: _onToggleHardMute,
|
||||
onToggleOutputMute: _toggleOutputMute,
|
||||
onConfigure: _onOpenVoiceSettings,
|
||||
onPttHeldChanged: _onOnscreenPttHeldChanged,
|
||||
);
|
||||
final snapshotView = _SnapshotView(
|
||||
snapshot: _snapshot!,
|
||||
currentVoiceChannelId: _currentVoiceChannelId,
|
||||
onJoinChannel: _onJoinChannel,
|
||||
onLeaveVoice: _onLeaveVoice,
|
||||
);
|
||||
const voiceBarWidthWide = 320.0;
|
||||
if (constraints.maxWidth >= wideBreakpoint) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: voiceBarWidthWide,
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
banner,
|
||||
const SizedBox(height: 12),
|
||||
voiceBar,
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: snapshotView),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(child: snapshotView),
|
||||
const SizedBox(height: 8),
|
||||
VoiceStatusChip(
|
||||
transmitMode: _transmitMode,
|
||||
releaseTailMs: _releaseTailMs,
|
||||
pttBoundKeyLabel: _pttBoundKeyLabel,
|
||||
audioStats: _audioStats,
|
||||
isTouchOnly: _isTouchOnlyPttHost,
|
||||
onTap: () => _onOpenVoiceDetailsSheet(),
|
||||
),
|
||||
if (_inChannel &&
|
||||
_transmitMode ==
|
||||
rust.BridgeTransmitMode.ptt) ...[
|
||||
const SizedBox(height: 8),
|
||||
VoicePttButton(
|
||||
active: _audioStats?.pttActive ?? false,
|
||||
onHeldChanged: _onOnscreenPttHeldChanged,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (_isMacOS) {
|
||||
return Scaffold(
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.only(top: _macOSTrafficLightPad),
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: headerTitle),
|
||||
...headerActions,
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(child: Padding(padding: const EdgeInsets.all(16), child: bodyContent)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: _AppBarTitle(
|
||||
phase: _phase,
|
||||
inChannel: _inChannel,
|
||||
channelName: _currentVoiceChannelName(),
|
||||
),
|
||||
actions: [
|
||||
// Narrow-mode AppBar gains mic-mute + headset-mute icons
|
||||
// when the user is in a voice channel, so the on-screen
|
||||
// body can dedicate ~85% of its height to the channel tree
|
||||
// + the bottom-anchored PTT button. Wide mode keeps these
|
||||
// controls inside the VoiceBar widget (left column) so the
|
||||
// signed-off rc.8 wide-layout doesn't shift.
|
||||
//
|
||||
// The gear icon (Icons.tune) that used to live here was
|
||||
// **removed** in the rc.8 follow-up: it duplicated the
|
||||
// "Adjust mode & release tail" button now inside the modal
|
||||
// sheet, and the user reported the duplication. The modal
|
||||
// is reached by tapping the status chip above the PTT
|
||||
// button — there is now exactly one entry point.
|
||||
if (_phase == _Phase.connected &&
|
||||
_inChannel &&
|
||||
MediaQuery.of(context).size.width < 840.0) ...[
|
||||
IconButton(
|
||||
tooltip: l10n.voiceHardMuteLabel,
|
||||
icon: Icon(_hardMute ? Icons.mic_off : Icons.mic),
|
||||
isSelected: _hardMute,
|
||||
selectedIcon: const Icon(Icons.mic_off),
|
||||
onPressed: _onToggleHardMute,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: l10n.voiceOutputMuteLabel,
|
||||
icon: Icon(_outputMuted ? Icons.headset_off : Icons.headset),
|
||||
isSelected: _outputMuted,
|
||||
selectedIcon: const Icon(Icons.headset_off),
|
||||
onPressed: _toggleOutputMute,
|
||||
),
|
||||
],
|
||||
IconButton(
|
||||
tooltip: l10n.aboutAction,
|
||||
icon: const Icon(Icons.info_outline),
|
||||
onPressed: () => _onShowAbout(context),
|
||||
),
|
||||
if (_phase == _Phase.connected) ...[
|
||||
// The previous Refresh action (Icons.refresh + _onRefresh)
|
||||
// was removed in v1.0.0-rc.8 — the snapshot stream the
|
||||
// bridge pushes via BridgeEvent::SnapshotChanged keeps the
|
||||
// tree current automatically, and a manual refresh was a
|
||||
// no-op from the user's perspective.
|
||||
IconButton(
|
||||
tooltip: l10n.disconnectAction,
|
||||
icon: const Icon(Icons.logout),
|
||||
onPressed: _onDisconnect,
|
||||
),
|
||||
],
|
||||
],
|
||||
title: headerTitle,
|
||||
actions: headerActions,
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: LayoutBuilder(
|
||||
builder: (ctx, bodyConstraints) {
|
||||
// Shared breakpoint with the inner snapshot LayoutBuilder
|
||||
// below — when the UI is wide enough to split into two
|
||||
// columns AND we are showing the snapshot, the
|
||||
// not-production-ready banner is moved into the left
|
||||
// column above the Voice Bar so it doesn't span the
|
||||
// wider channel-tree area. In every other state (narrow,
|
||||
// or idle / connecting at any width) the banner stays
|
||||
// pinned to the top of the body.
|
||||
const wideBreakpoint = 840.0;
|
||||
final isWideSnapshot =
|
||||
bodyConstraints.maxWidth >= wideBreakpoint &&
|
||||
_phase == _Phase.connected &&
|
||||
_snapshot != null;
|
||||
final banner = Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.tertiaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
l10n.homeNotProductionReadyBanner,
|
||||
style: TextStyle(color: theme.colorScheme.onTertiaryContainer),
|
||||
),
|
||||
);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (!isWideSnapshot) ...[banner, const SizedBox(height: 12)],
|
||||
Text(statusText(), style: theme.textTheme.titleMedium),
|
||||
if (_lostReason != null || _reconnectAttempt != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_reconnectAttempt != null
|
||||
? l10n.statusReconnecting(
|
||||
_reconnectAttempt!,
|
||||
_reconnectDelay ?? 0,
|
||||
)
|
||||
: l10n.statusConnectionLost(_lostReason ?? ''),
|
||||
style: TextStyle(
|
||||
color: theme.colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
if (_phase == _Phase.idle) ...[
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
// Dismiss keyboard when the user drags away from
|
||||
// a focused field — friendlier mobile UX than
|
||||
// forcing them to tap outside.
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_ConnectForm(
|
||||
hostCtl: _hostCtl,
|
||||
nickCtl: _nickCtl,
|
||||
passwordCtl: _passwordCtl,
|
||||
onConnect: () => _onConnect(),
|
||||
onAddBookmark: _onAddCurrentBookmark,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_BookmarkList(
|
||||
bookmarks: _bookmarks,
|
||||
onConnect: _onUseBookmark,
|
||||
onDelete: _onDeleteBookmark,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
] else if (_phase == _Phase.connecting) ...[
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(32),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
] else if (_phase == _Phase.connected && _snapshot != null) ...[
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (ctx, constraints) {
|
||||
final voiceBar = VoiceBar(
|
||||
inChannel: _inChannel,
|
||||
transmitMode: _transmitMode,
|
||||
hardMute: _hardMute,
|
||||
outputMuted: _outputMuted,
|
||||
releaseTailMs: _releaseTailMs,
|
||||
channelName: _currentVoiceChannelName(),
|
||||
audioStats: _audioStats,
|
||||
pttLevel: _pttLevel,
|
||||
pttBackendId: _pttBackendId,
|
||||
pttBoundInputClass: _pttBoundInputClass,
|
||||
pttBoundKeyLabel: _pttBoundKeyLabel,
|
||||
onToggleMute: _onToggleHardMute,
|
||||
onToggleOutputMute: _toggleOutputMute,
|
||||
onConfigure: _onOpenVoiceSettings,
|
||||
onPttHeldChanged: _onOnscreenPttHeldChanged,
|
||||
);
|
||||
final snapshotView = _SnapshotView(
|
||||
snapshot: _snapshot!,
|
||||
currentVoiceChannelId: _currentVoiceChannelId,
|
||||
onJoinChannel: _onJoinChannel,
|
||||
onLeaveVoice: _onLeaveVoice,
|
||||
);
|
||||
// Responsive: at <840 dp use a stacked layout
|
||||
// (Voice Bar on top, channel tree below). At
|
||||
// ≥840 dp use a side-by-side layout with the
|
||||
// Voice Bar pinned to 320 dp on the left and
|
||||
// the channel tree expanding on the right.
|
||||
// 840 dp matches Material's tablet / desktop
|
||||
// breakpoint.
|
||||
const wideBreakpoint = 840.0;
|
||||
const voiceBarWidthWide = 320.0;
|
||||
if (constraints.maxWidth >= wideBreakpoint) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: voiceBarWidthWide,
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
banner,
|
||||
const SizedBox(height: 12),
|
||||
voiceBar,
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: snapshotView),
|
||||
],
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// In narrow / one-column layout the layout
|
||||
// is:
|
||||
//
|
||||
// [ channel tree (Expanded) ]
|
||||
// [ status chip (2-line live readout) ]
|
||||
// [ PTT button (mobile-only,
|
||||
// PTT-mode-only) ]
|
||||
//
|
||||
// The chip is a tap target that opens
|
||||
// `showVoiceDetailsSheet` with the mic
|
||||
// level meter + TX/RX counts + capability
|
||||
// badge. Mode + bind key + release-tail
|
||||
// live in `VoiceSettingsDialog`
|
||||
// (gear icon in AppBar).
|
||||
//
|
||||
// Wide mode (Row branch above) is
|
||||
// unchanged from rc.8.
|
||||
Expanded(child: snapshotView),
|
||||
const SizedBox(height: 8),
|
||||
VoiceStatusChip(
|
||||
transmitMode: _transmitMode,
|
||||
releaseTailMs: _releaseTailMs,
|
||||
pttBoundKeyLabel: _pttBoundKeyLabel,
|
||||
audioStats: _audioStats,
|
||||
isTouchOnly: _isTouchOnlyPttHost,
|
||||
onTap: () => _onOpenVoiceDetailsSheet(),
|
||||
),
|
||||
// PTT button only when PTT mode is active
|
||||
// AND the user is in a voice channel. In
|
||||
// Continuous mode there is nothing to
|
||||
// hold; the chip alone surfaces the
|
||||
// "Mic on / off" state.
|
||||
if (_inChannel &&
|
||||
_transmitMode ==
|
||||
rust.BridgeTransmitMode.ptt) ...[
|
||||
const SizedBox(height: 8),
|
||||
VoicePttButton(
|
||||
active: _audioStats?.pttActive ?? false,
|
||||
onHeldChanged: _onOnscreenPttHeldChanged,
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
child: bodyContent,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<string>Chanora</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Chanora</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
|
||||
@@ -8,8 +8,13 @@ class MainFlutterWindow: NSWindow {
|
||||
self.contentViewController = flutterViewController
|
||||
self.setFrame(windowFrame, display: true)
|
||||
|
||||
self.titlebarAppearsTransparent = true
|
||||
self.titleVisibility = .hidden
|
||||
self.styleMask.insert(.fullSizeContentView)
|
||||
self.isMovableByWindowBackground = true
|
||||
|
||||
RegisterGeneratedPlugins(registry: flutterViewController)
|
||||
|
||||
super.awakeFromNib()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user