Plezy is edge-to-edge on Android whether it asks to be or not: targetSdk is 36, Android 15 enforces edge-to-edge for apps targeting 35+, and Android 16 disables the windowOptOutEdgeToEdgeEnforcement escape hatch. The only SystemUiMode.edgeToEdge call in the app fires on video-player exit, so on API 35+ the window is edge-to-edge from the first frame and MediaQuery.padding.bottom is a real ~48dp overlap under 3-button navigation. MainScreen's phone layout hides that. It supplies a bottomNavigationBar and never sets extendBody, so Flutter's Scaffold strips padding.bottom from the body MediaQuery and every tab is already safe. Routes pushed on the profile navigator are full-screen siblings of MainScreen with no bottom bar, so they receive the untouched inset and nothing consumes it - the last settings card and the final log lines render under the back, home, and recents buttons. Three shared hosts own most of those routes, so the inset is consumed there: FocusedScrollScaffold (25 screens, counting the SettingsPage wrapper) and FocusableDetailScreenMixin.buildDetailScaffold (4) now append a trailing SliverSystemBottomInset, and the four screens that build their own Scaffold around a CustomScrollView append it directly. The new widget codifies the convention this repository had already written down but open-coded - insets baked into the scroll content rather than a SafeArea around the scroll view - so content still paints under the bar while the scroll extent grows enough to bring the last row above it. It reads padding from its own context and collapses to zero height wherever the inset is already zero: desktop, Android TV, tvOS via _AppleTvScale, and inside MainScreen's tab bodies. No platform branching, and it stacks additively with the music detail screens' existing mini-player spacers, which is correct because the mini-player itself floats above the navigation bar on a pushed route. Scroll views that are not sliver lists take the inset in their own padding: the companion remote's ListView, the auth screen's scroll container, and the two SliverFillRemaining sign-in forms, whose children size themselves from the extent remaining before them and so cannot be helped by a trailing sliver. The logs empty state is left alone for the same reason inverted - it already fills the viewport, and a trailing inset would only add scroll slack. Verified on a Pixel 7 running Android 16 (API 36) with 3-button navigation: Settings, Logs, and Video Playback all end clear of the bar. close #1766
749 lines
24 KiB
Dart
749 lines
24 KiB
Dart
import 'dart:math';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../../focus/focusable_text_field.dart';
|
|
import '../../models/companion_remote/remote_command.dart';
|
|
import '../../models/companion_remote/remote_session.dart';
|
|
import '../../i18n/strings.g.dart';
|
|
import '../../mixins/controller_disposer_mixin.dart';
|
|
import '../../providers/companion_remote_provider.dart';
|
|
import '../../utils/platform_detector.dart';
|
|
import '../../theme/mono_tokens.dart';
|
|
import '../../utils/app_logger.dart';
|
|
import '../../utils/dialogs.dart';
|
|
import '../../widgets/companion_remote/discovery_view.dart';
|
|
import '../../widgets/app_icon.dart';
|
|
import '../../widgets/overlay_sheet.dart';
|
|
import '../../widgets/pill_input_decoration.dart';
|
|
|
|
class MobileRemoteScreen extends StatefulWidget {
|
|
const MobileRemoteScreen({super.key});
|
|
|
|
@override
|
|
State<MobileRemoteScreen> createState() => _MobileRemoteScreenState();
|
|
}
|
|
|
|
class _MobileRemoteScreenState extends State<MobileRemoteScreen> {
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return OverlaySheetHost(
|
|
// Close an open sheet on system back instead of popping the screen.
|
|
canPop: true,
|
|
child: Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(t.companionRemote.title),
|
|
actions: [
|
|
Consumer<CompanionRemoteProvider>(
|
|
builder: (context, provider, child) {
|
|
if (provider.isConnected) {
|
|
return IconButton(
|
|
icon: const AppIcon(Symbols.link_off_rounded),
|
|
onPressed: () async {
|
|
final confirmed = await showConfirmDialog(
|
|
context,
|
|
title: t.common.disconnect,
|
|
message: t.companionRemote.remote.disconnectConfirm,
|
|
confirmText: t.common.disconnect,
|
|
isDestructive: true,
|
|
);
|
|
|
|
if (confirmed && context.mounted) {
|
|
await context.read<CompanionRemoteProvider>().leaveSession();
|
|
}
|
|
},
|
|
tooltip: t.common.disconnect,
|
|
);
|
|
}
|
|
return const SizedBox.shrink();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
body: Consumer<CompanionRemoteProvider>(
|
|
builder: (context, provider, child) {
|
|
if (provider.status == RemoteSessionStatus.reconnecting) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: .center,
|
|
children: [
|
|
const CircularProgressIndicator(),
|
|
const SizedBox(height: 24),
|
|
Text(t.companionRemote.remote.reconnecting, style: Theme.of(context).textTheme.titleLarge),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
t.companionRemote.remote.attemptOf(current: provider.reconnectAttempts),
|
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: tokens(context).textMuted),
|
|
),
|
|
const SizedBox(height: 32),
|
|
Row(
|
|
mainAxisAlignment: .center,
|
|
children: [
|
|
OutlinedButton(
|
|
onPressed: () async {
|
|
await provider.cancelReconnect();
|
|
},
|
|
child: Text(t.common.cancel),
|
|
),
|
|
const SizedBox(width: 16),
|
|
FilledButton(
|
|
onPressed: () async {
|
|
await provider.retryReconnectNow();
|
|
},
|
|
child: Text(t.companionRemote.remote.retryNow),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
if (!provider.isConnected) {
|
|
return const DiscoveryView();
|
|
}
|
|
|
|
return const _RemoteControlLayout();
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RemoteControlLayout extends StatelessWidget {
|
|
const _RemoteControlLayout();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (PlatformDetector.isDesktop(context)) {
|
|
return Center(
|
|
child: ConstrainedBox(constraints: const BoxConstraints(maxWidth: 400), child: const _RemoteControlContent()),
|
|
);
|
|
}
|
|
|
|
return const _RemoteControlContent();
|
|
}
|
|
}
|
|
|
|
class _RemoteControlContent extends StatefulWidget {
|
|
const _RemoteControlContent();
|
|
|
|
@override
|
|
State<_RemoteControlContent> createState() => _RemoteControlContentState();
|
|
}
|
|
|
|
class _RemoteControlContentState extends State<_RemoteControlContent> {
|
|
int _selectedTab = 0;
|
|
|
|
void _showSearchSheet({bool switchToSearchTab = false}) {
|
|
if (switchToSearchTab) {
|
|
_sendCommand(RemoteCommandType.tabSearch);
|
|
}
|
|
final provider = context.read<CompanionRemoteProvider>();
|
|
OverlaySheetController.of(
|
|
context,
|
|
).show(showDragHandle: true, builder: (_) => _SearchBottomSheet(provider: provider));
|
|
}
|
|
|
|
void _sendCommand(RemoteCommandType type) {
|
|
HapticFeedback.lightImpact();
|
|
appLogger.d('MobileRemoteScreen: Sending command: $type');
|
|
final provider = context.read<CompanionRemoteProvider>();
|
|
provider.sendCommand(type);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
children: [
|
|
Consumer<CompanionRemoteProvider>(
|
|
builder: (context, provider, child) {
|
|
final device = provider.connectedDevice;
|
|
if (device == null) return const SizedBox.shrink();
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
color: Theme.of(context).colorScheme.primaryContainer,
|
|
child: Row(
|
|
children: [
|
|
AppIcon(Symbols.computer_rounded, color: Theme.of(context).colorScheme.onPrimaryContainer),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: .start,
|
|
children: [
|
|
Text(
|
|
device.name,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.titleMedium?.copyWith(color: Theme.of(context).colorScheme.onPrimaryContainer),
|
|
),
|
|
Text(
|
|
device.platform,
|
|
style: Theme.of(
|
|
context,
|
|
).textTheme.bodySmall?.copyWith(color: Theme.of(context).colorScheme.onPrimaryContainer),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Container(
|
|
width: 8,
|
|
height: 8,
|
|
decoration: const BoxDecoration(color: Colors.green, shape: BoxShape.circle),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
Expanded(
|
|
child: ListView(
|
|
padding: .fromLTRB(16, 16, 16, 16 + MediaQuery.paddingOf(context).bottom),
|
|
children: [
|
|
SegmentedButton<int>(
|
|
showSelectedIcon: false,
|
|
segments: [
|
|
ButtonSegment(
|
|
value: 0,
|
|
label: Text(t.companionRemote.remote.tabRemote),
|
|
icon: const AppIcon(Symbols.navigation_rounded),
|
|
),
|
|
ButtonSegment(
|
|
value: 1,
|
|
label: Text(t.companionRemote.remote.tabPlay),
|
|
icon: const AppIcon(Symbols.play_arrow_rounded),
|
|
),
|
|
ButtonSegment(
|
|
value: 2,
|
|
label: Text(t.companionRemote.remote.tabMore),
|
|
icon: const AppIcon(Symbols.flash_on_rounded),
|
|
),
|
|
],
|
|
selected: {_selectedTab},
|
|
onSelectionChanged: (Set<int> selection) {
|
|
setState(() {
|
|
_selectedTab = selection.first;
|
|
});
|
|
},
|
|
),
|
|
const SizedBox(height: 24),
|
|
if (_selectedTab == 0) _buildNavigationTab(),
|
|
if (_selectedTab == 1) _buildPlaybackTab(),
|
|
if (_selectedTab == 2) _buildQuickActionsTab(),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildNavigationTab() {
|
|
final isPlayerActive = context.watch<CompanionRemoteProvider>().isPlayerActive;
|
|
|
|
return Column(
|
|
children: [
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
mainAxisAlignment: .spaceEvenly,
|
|
children: [
|
|
_RemoteButton(
|
|
icon: Symbols.home_rounded,
|
|
label: t.common.home,
|
|
onPressed: () => _sendCommand(RemoteCommandType.home),
|
|
),
|
|
_RemoteButton(
|
|
icon: Symbols.arrow_back_rounded,
|
|
label: t.common.back,
|
|
onPressed: () => _sendCommand(RemoteCommandType.back),
|
|
),
|
|
_RemoteButton(
|
|
icon: Symbols.menu_rounded,
|
|
label: t.companionRemote.remote.menu,
|
|
onPressed: () => _sendCommand(RemoteCommandType.contextMenu),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 32),
|
|
Center(child: _DPad(onCommand: _sendCommand)),
|
|
if (!isPlayerActive) ...[
|
|
const SizedBox(height: 32),
|
|
Text(t.companionRemote.remote.tabNavigation, style: Theme.of(context).textTheme.titleMedium),
|
|
const SizedBox(height: 16),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
alignment: WrapAlignment.center,
|
|
children: [
|
|
_RemoteChip(
|
|
icon: Symbols.explore_rounded,
|
|
label: t.companionRemote.remote.tabDiscover,
|
|
onPressed: () => _sendCommand(RemoteCommandType.tabDiscover),
|
|
),
|
|
_RemoteChip(
|
|
icon: Symbols.video_library_rounded,
|
|
label: t.companionRemote.remote.tabLibraries,
|
|
onPressed: () => _sendCommand(RemoteCommandType.tabLibraries),
|
|
),
|
|
_RemoteChip(
|
|
icon: Symbols.search_rounded,
|
|
label: t.companionRemote.remote.tabSearch,
|
|
onPressed: () => _showSearchSheet(switchToSearchTab: true),
|
|
),
|
|
_RemoteChip(
|
|
icon: Symbols.download_rounded,
|
|
label: t.companionRemote.remote.tabDownloads,
|
|
onPressed: () => _sendCommand(RemoteCommandType.tabDownloads),
|
|
),
|
|
_RemoteChip(
|
|
icon: Symbols.settings_rounded,
|
|
label: t.companionRemote.remote.tabSettings,
|
|
onPressed: () => _sendCommand(RemoteCommandType.tabSettings),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildPlaybackTab() {
|
|
return Column(
|
|
children: [
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
mainAxisAlignment: .center,
|
|
children: [
|
|
_RemoteButton(
|
|
icon: Symbols.skip_previous_rounded,
|
|
label: t.companionRemote.remote.previous,
|
|
onPressed: () => _sendCommand(RemoteCommandType.previousTrack),
|
|
),
|
|
const SizedBox(width: 16),
|
|
_RemoteButton(
|
|
icon: Symbols.play_arrow_rounded,
|
|
label: t.companionRemote.remote.playPause,
|
|
size: 64,
|
|
iconSize: 36,
|
|
onPressed: () => _sendCommand(RemoteCommandType.playPause),
|
|
),
|
|
const SizedBox(width: 16),
|
|
_RemoteButton(
|
|
icon: Symbols.skip_next_rounded,
|
|
label: t.companionRemote.remote.next,
|
|
onPressed: () => _sendCommand(RemoteCommandType.nextTrack),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
Row(
|
|
mainAxisAlignment: .center,
|
|
children: [
|
|
_RemoteButton(
|
|
icon: Symbols.replay_10_rounded,
|
|
label: t.companionRemote.remote.seekBack,
|
|
onPressed: () => _sendCommand(RemoteCommandType.seekBackward),
|
|
),
|
|
const SizedBox(width: 16),
|
|
_RemoteButton(
|
|
icon: Symbols.stop_rounded,
|
|
label: t.companionRemote.remote.stop,
|
|
onPressed: () => _sendCommand(RemoteCommandType.stop),
|
|
),
|
|
const SizedBox(width: 16),
|
|
_RemoteButton(
|
|
icon: Symbols.forward_10_rounded,
|
|
label: t.companionRemote.remote.seekForward,
|
|
onPressed: () => _sendCommand(RemoteCommandType.seekForward),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 32),
|
|
Text(t.companionRemote.remote.volume, style: Theme.of(context).textTheme.titleMedium),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
mainAxisAlignment: .center,
|
|
children: [
|
|
_RemoteButton(
|
|
icon: Symbols.volume_off_rounded,
|
|
label: t.common.mute,
|
|
onPressed: () => _sendCommand(RemoteCommandType.volumeMute),
|
|
),
|
|
const SizedBox(width: 16),
|
|
_RemoteButton(
|
|
icon: Symbols.volume_down_rounded,
|
|
label: t.companionRemote.remote.volumeDown,
|
|
onPressed: () => _sendCommand(RemoteCommandType.volumeDown),
|
|
),
|
|
const SizedBox(width: 16),
|
|
_RemoteButton(
|
|
icon: Symbols.volume_up_rounded,
|
|
label: t.companionRemote.remote.volumeUp,
|
|
onPressed: () => _sendCommand(RemoteCommandType.volumeUp),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildQuickActionsTab() {
|
|
final isPlayerActive = context.watch<CompanionRemoteProvider>().isPlayerActive;
|
|
|
|
return Column(
|
|
children: [
|
|
const SizedBox(height: 16),
|
|
Wrap(
|
|
spacing: 12,
|
|
runSpacing: 12,
|
|
alignment: WrapAlignment.center,
|
|
children: [
|
|
if (!isPlayerActive)
|
|
_RemoteCard(icon: Symbols.search_rounded, label: t.common.search, onPressed: _showSearchSheet),
|
|
if (isPlayerActive) ...[
|
|
_RemoteCard(
|
|
icon: Symbols.fullscreen_rounded,
|
|
label: t.companionRemote.remote.fullscreen,
|
|
onPressed: () => _sendCommand(RemoteCommandType.fullscreen),
|
|
),
|
|
_RemoteCard(
|
|
icon: Symbols.subtitles_rounded,
|
|
label: t.companionRemote.remote.subtitles,
|
|
onPressed: () => _sendCommand(RemoteCommandType.subtitles),
|
|
),
|
|
_RemoteCard(
|
|
icon: Symbols.audiotrack_rounded,
|
|
label: t.companionRemote.remote.audio,
|
|
onPressed: () => _sendCommand(RemoteCommandType.audioTracks),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DPad extends StatelessWidget {
|
|
final Function(RemoteCommandType) onCommand;
|
|
|
|
const _DPad({required this.onCommand});
|
|
|
|
static const _diameter = 220.0;
|
|
static const _centerSize = 80.0;
|
|
static const _gapWidth = 3.0;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = Theme.of(context).colorScheme;
|
|
|
|
return SizedBox(
|
|
width: _diameter,
|
|
height: _diameter,
|
|
child: ClipOval(
|
|
child: Stack(
|
|
children: [
|
|
Container(
|
|
decoration: BoxDecoration(shape: BoxShape.circle, color: colors.surface),
|
|
),
|
|
_DPadZone(
|
|
startAngle: -135,
|
|
icon: Symbols.keyboard_arrow_up_rounded,
|
|
onTap: () => onCommand(RemoteCommandType.dpadUp),
|
|
),
|
|
_DPadZone(
|
|
startAngle: -45,
|
|
icon: Symbols.keyboard_arrow_right_rounded,
|
|
onTap: () => onCommand(RemoteCommandType.dpadRight),
|
|
),
|
|
_DPadZone(
|
|
startAngle: 45,
|
|
icon: Symbols.keyboard_arrow_down_rounded,
|
|
onTap: () => onCommand(RemoteCommandType.dpadDown),
|
|
),
|
|
_DPadZone(
|
|
startAngle: 135,
|
|
icon: Symbols.keyboard_arrow_left_rounded,
|
|
onTap: () => onCommand(RemoteCommandType.dpadLeft),
|
|
),
|
|
Center(child: _DPadCenter(onTap: () => onCommand(RemoteCommandType.select))),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DPadZone extends StatefulWidget {
|
|
final double startAngle;
|
|
final IconData icon;
|
|
final VoidCallback onTap;
|
|
|
|
const _DPadZone({required this.startAngle, required this.icon, required this.onTap});
|
|
|
|
Alignment get iconAlignment {
|
|
final midRad = (startAngle + 45) * pi / 180;
|
|
const r = 0.78;
|
|
return Alignment(r * cos(midRad), r * sin(midRad));
|
|
}
|
|
|
|
@override
|
|
State<_DPadZone> createState() => _DPadZoneState();
|
|
}
|
|
|
|
class _DPadZoneState extends State<_DPadZone> {
|
|
bool _pressed = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = Theme.of(context).colorScheme;
|
|
|
|
return Positioned.fill(
|
|
child: ClipPath(
|
|
clipper: _SectorClipper(
|
|
startAngle: widget.startAngle,
|
|
innerRadius: _DPad._centerSize / 2 + _DPad._gapWidth,
|
|
gapWidth: _DPad._gapWidth,
|
|
),
|
|
child: GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTapDown: (_) => setState(() => _pressed = true),
|
|
onTapUp: (_) => setState(() => _pressed = false),
|
|
onTapCancel: () => setState(() => _pressed = false),
|
|
onTap: () {
|
|
HapticFeedback.lightImpact();
|
|
widget.onTap();
|
|
},
|
|
child: AnimatedContainer(
|
|
duration: tokens(context).fast,
|
|
color: _pressed ? colors.primary.withValues(alpha: 0.8) : colors.primary,
|
|
alignment: widget.iconAlignment,
|
|
child: AppIcon(widget.icon, size: 28, color: colors.onPrimary),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DPadCenter extends StatefulWidget {
|
|
final VoidCallback onTap;
|
|
|
|
const _DPadCenter({required this.onTap});
|
|
|
|
@override
|
|
State<_DPadCenter> createState() => _DPadCenterState();
|
|
}
|
|
|
|
class _DPadCenterState extends State<_DPadCenter> {
|
|
bool _pressed = false;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colors = Theme.of(context).colorScheme;
|
|
|
|
return GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
onTapDown: (_) => setState(() => _pressed = true),
|
|
onTapUp: (_) => setState(() => _pressed = false),
|
|
onTapCancel: () => setState(() => _pressed = false),
|
|
onTap: () {
|
|
HapticFeedback.lightImpact();
|
|
widget.onTap();
|
|
},
|
|
child: AnimatedContainer(
|
|
duration: tokens(context).fast,
|
|
width: _DPad._centerSize,
|
|
height: _DPad._centerSize,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: _pressed ? colors.primary.withValues(alpha: 0.8) : colors.primary,
|
|
),
|
|
child: const SizedBox.shrink(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SectorClipper extends CustomClipper<Path> {
|
|
final double startAngle;
|
|
final double innerRadius;
|
|
final double gapWidth;
|
|
|
|
const _SectorClipper({required this.startAngle, required this.innerRadius, required this.gapWidth});
|
|
|
|
@override
|
|
Path getClip(Size size) {
|
|
final center = Offset(size.width / 2, size.height / 2);
|
|
final outerRadius = size.width / 2;
|
|
final halfGap = gapWidth / 2;
|
|
final startRad = startAngle * pi / 180;
|
|
final endRad = startRad + pi / 2;
|
|
|
|
// Use arcsin to compute angular offset at each radius for constant-width gap
|
|
final innerOffset = asin(halfGap / innerRadius);
|
|
final outerOffset = asin(halfGap / outerRadius);
|
|
|
|
final innerStart = startRad + innerOffset;
|
|
final outerStart = startRad + outerOffset;
|
|
final outerEnd = endRad - outerOffset;
|
|
final innerEnd = endRad - innerOffset;
|
|
|
|
return Path()
|
|
..moveTo(center.dx + innerRadius * cos(innerStart), center.dy + innerRadius * sin(innerStart))
|
|
..lineTo(center.dx + outerRadius * cos(outerStart), center.dy + outerRadius * sin(outerStart))
|
|
..arcTo(Rect.fromCircle(center: center, radius: outerRadius), outerStart, outerEnd - outerStart, false)
|
|
..lineTo(center.dx + innerRadius * cos(innerEnd), center.dy + innerRadius * sin(innerEnd))
|
|
..arcTo(Rect.fromCircle(center: center, radius: innerRadius), innerEnd, innerStart - innerEnd, false)
|
|
..close();
|
|
}
|
|
|
|
@override
|
|
bool shouldReclip(covariant _SectorClipper oldClipper) =>
|
|
startAngle != oldClipper.startAngle || innerRadius != oldClipper.innerRadius || gapWidth != oldClipper.gapWidth;
|
|
}
|
|
|
|
class _RemoteButton extends StatelessWidget {
|
|
final IconData icon;
|
|
final String label;
|
|
final VoidCallback onPressed;
|
|
final double size;
|
|
final double iconSize;
|
|
|
|
const _RemoteButton({
|
|
required this.icon,
|
|
required this.label,
|
|
required this.onPressed,
|
|
this.size = 56,
|
|
this.iconSize = 24,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Column(
|
|
mainAxisSize: .min,
|
|
children: [
|
|
SizedBox(
|
|
width: size,
|
|
height: size,
|
|
child: FilledButton(
|
|
onPressed: () {
|
|
HapticFeedback.lightImpact();
|
|
onPressed();
|
|
},
|
|
style: FilledButton.styleFrom(padding: .zero, shape: const CircleBorder()),
|
|
child: AppIcon(icon, size: iconSize),
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(label, style: Theme.of(context).textTheme.bodySmall, textAlign: TextAlign.center),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RemoteChip extends StatelessWidget {
|
|
final IconData icon;
|
|
final String label;
|
|
final VoidCallback onPressed;
|
|
|
|
const _RemoteChip({required this.icon, required this.label, required this.onPressed});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return FilledButton.icon(
|
|
onPressed: () {
|
|
HapticFeedback.lightImpact();
|
|
onPressed();
|
|
},
|
|
icon: AppIcon(icon, size: 18),
|
|
label: Text(label),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _SearchBottomSheet extends StatefulWidget {
|
|
final CompanionRemoteProvider provider;
|
|
|
|
const _SearchBottomSheet({required this.provider});
|
|
|
|
@override
|
|
State<_SearchBottomSheet> createState() => _SearchBottomSheetState();
|
|
}
|
|
|
|
class _SearchBottomSheetState extends State<_SearchBottomSheet> with ControllerDisposerMixin {
|
|
late final _controller = createTextEditingController();
|
|
|
|
void _submit(String text) {
|
|
final trimmed = text.trim();
|
|
if (trimmed.isNotEmpty) {
|
|
widget.provider.sendCommand(RemoteCommandType.search, data: {'query': trimmed});
|
|
}
|
|
OverlaySheetController.of(context).close();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: .only(bottom: MediaQuery.viewInsetsOf(context).bottom, left: 16, right: 16, top: 16),
|
|
child: Column(
|
|
mainAxisSize: .min,
|
|
children: [
|
|
FocusableTextField(
|
|
controller: _controller,
|
|
autofocus: true,
|
|
decoration: pillInputDecoration(
|
|
context,
|
|
hintText: t.companionRemote.remote.searchHint,
|
|
prefixIcon: const AppIcon(Symbols.search_rounded),
|
|
suffixIcon: IconButton(
|
|
icon: const AppIcon(Symbols.send_rounded),
|
|
onPressed: () => _submit(_controller.text),
|
|
),
|
|
),
|
|
onSubmitted: _submit,
|
|
),
|
|
const SizedBox(height: 16),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RemoteCard extends StatelessWidget {
|
|
final IconData icon;
|
|
final String label;
|
|
final VoidCallback onPressed;
|
|
|
|
const _RemoteCard({required this.icon, required this.label, required this.onPressed});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SizedBox(
|
|
width: 100,
|
|
height: 100,
|
|
child: Card(
|
|
child: InkWell(
|
|
onTap: () {
|
|
HapticFeedback.lightImpact();
|
|
onPressed();
|
|
},
|
|
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
|
child: Column(
|
|
mainAxisAlignment: .center,
|
|
children: [
|
|
AppIcon(icon, size: 32),
|
|
const SizedBox(height: 8),
|
|
Text(label, style: Theme.of(context).textTheme.bodySmall, textAlign: TextAlign.center),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|