Add Companion Remote for mobile-to-desktop control
This commit is contained in:
@@ -0,0 +1,731 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../models/companion_remote/remote_command_type.dart';
|
||||
import '../../models/companion_remote/remote_session.dart';
|
||||
import '../../providers/companion_remote_provider.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import 'pairing_screen.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 Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Companion Remote'),
|
||||
actions: [
|
||||
Consumer<CompanionRemoteProvider>(
|
||||
builder: (context, provider, child) {
|
||||
if (provider.isConnected) {
|
||||
return IconButton(
|
||||
icon: const Icon(Icons.link_off),
|
||||
onPressed: () async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Disconnect'),
|
||||
content: const Text('Do you want to disconnect from the remote session?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Disconnect'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true && context.mounted) {
|
||||
await context.read<CompanionRemoteProvider>().leaveSession();
|
||||
}
|
||||
},
|
||||
tooltip: 'Disconnect',
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Consumer<CompanionRemoteProvider>(
|
||||
builder: (context, provider, child) {
|
||||
if (provider.status == RemoteSessionStatus.reconnecting) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
'Reconnecting...',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Attempt ${provider.reconnectAttempts} of 5',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
OutlinedButton(
|
||||
onPressed: () => provider.cancelReconnect(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
FilledButton(
|
||||
onPressed: () => provider.retryReconnectNow(),
|
||||
child: const Text('Retry Now'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!provider.isConnected) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.phonelink_off, size: 64, color: Colors.grey),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
provider.status == RemoteSessionStatus.error
|
||||
? provider.session?.errorMessage ?? 'Connection error'
|
||||
: 'Not connected',
|
||||
style: const TextStyle(fontSize: 20, color: Colors.grey),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const PairingScreen()),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.link),
|
||||
label: const Text('Connect to Device'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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>();
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (_) => _SearchBottomSheet(provider: provider),
|
||||
);
|
||||
}
|
||||
|
||||
void _sendCommand(RemoteCommandType type) {
|
||||
HapticFeedback.lightImpact();
|
||||
print('🔴 MobileRemoteScreen: Button pressed! Type: $type');
|
||||
appLogger.d('MobileRemoteScreen: Sending command: $type');
|
||||
final provider = context.read<CompanionRemoteProvider>();
|
||||
print('🔴 Provider isConnected: ${provider.isConnected}');
|
||||
provider.sendCommand(type);
|
||||
print('🔴 sendCommand called');
|
||||
}
|
||||
|
||||
@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: [
|
||||
Icon(
|
||||
Icons.computer,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: 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: BoxDecoration(
|
||||
color: Colors.green,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
SegmentedButton<int>(
|
||||
segments: const [
|
||||
ButtonSegment(value: 0, label: Text('Navigate'), icon: Icon(Icons.navigation)),
|
||||
ButtonSegment(value: 1, label: Text('Playback'), icon: Icon(Icons.play_arrow)),
|
||||
ButtonSegment(value: 2, label: Text('Quick'), icon: Icon(Icons.flash_on)),
|
||||
],
|
||||
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() {
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_RemoteButton(
|
||||
icon: Icons.home,
|
||||
label: 'Home',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.home),
|
||||
),
|
||||
_RemoteButton(
|
||||
icon: Icons.arrow_back,
|
||||
label: 'Back',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.back),
|
||||
),
|
||||
_RemoteButton(
|
||||
icon: Icons.menu,
|
||||
label: 'Menu',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.contextMenu),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Center(
|
||||
child: _DPad(onCommand: _sendCommand),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
'Tab Navigation',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
_RemoteChip(
|
||||
icon: Icons.explore,
|
||||
label: 'Discover',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.tabDiscover),
|
||||
),
|
||||
_RemoteChip(
|
||||
icon: Icons.video_library,
|
||||
label: 'Libraries',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.tabLibraries),
|
||||
),
|
||||
_RemoteChip(
|
||||
icon: Icons.search,
|
||||
label: 'Search',
|
||||
onPressed: () => _showSearchSheet(switchToSearchTab: true),
|
||||
),
|
||||
_RemoteChip(
|
||||
icon: Icons.download,
|
||||
label: 'Downloads',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.tabDownloads),
|
||||
),
|
||||
_RemoteChip(
|
||||
icon: Icons.settings,
|
||||
label: 'Settings',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.tabSettings),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaybackTab() {
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_RemoteButton(
|
||||
icon: Icons.skip_previous,
|
||||
label: 'Previous',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.previousTrack),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_RemoteButton(
|
||||
icon: Icons.play_arrow,
|
||||
label: 'Play/Pause',
|
||||
size: 64,
|
||||
iconSize: 36,
|
||||
onPressed: () => _sendCommand(RemoteCommandType.playPause),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_RemoteButton(
|
||||
icon: Icons.skip_next,
|
||||
label: 'Next',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.nextTrack),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_RemoteButton(
|
||||
icon: Icons.replay_10,
|
||||
label: 'Seek Back',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.seekBackward),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_RemoteButton(
|
||||
icon: Icons.stop,
|
||||
label: 'Stop',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.stop),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_RemoteButton(
|
||||
icon: Icons.forward_10,
|
||||
label: 'Seek Fwd',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.seekForward),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
'Volume',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_RemoteButton(
|
||||
icon: Icons.volume_off,
|
||||
label: 'Mute',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.volumeMute),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_RemoteButton(
|
||||
icon: Icons.volume_down,
|
||||
label: 'Down',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.volumeDown),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_RemoteButton(
|
||||
icon: Icons.volume_up,
|
||||
label: 'Up',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.volumeUp),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuickActionsTab() {
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
_RemoteCard(
|
||||
icon: Icons.search,
|
||||
label: 'Search',
|
||||
onPressed: _showSearchSheet,
|
||||
),
|
||||
_RemoteCard(
|
||||
icon: Icons.fullscreen,
|
||||
label: 'Fullscreen',
|
||||
onPressed: () => _sendCommand(RemoteCommandType.fullscreen),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DPad extends StatelessWidget {
|
||||
final Function(RemoteCommandType) onCommand;
|
||||
|
||||
const _DPad({required this.onCommand});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const size = 80.0;
|
||||
const centerSize = 60.0;
|
||||
|
||||
return SizedBox(
|
||||
width: size * 3,
|
||||
height: size * 3,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
left: size,
|
||||
top: 0,
|
||||
child: _DPadButton(
|
||||
icon: Icons.arrow_drop_up,
|
||||
onPressed: () => onCommand(RemoteCommandType.dpadUp),
|
||||
size: size,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: size,
|
||||
bottom: 0,
|
||||
child: _DPadButton(
|
||||
icon: Icons.arrow_drop_down,
|
||||
onPressed: () => onCommand(RemoteCommandType.dpadDown),
|
||||
size: size,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: size,
|
||||
child: _DPadButton(
|
||||
icon: Icons.arrow_left,
|
||||
onPressed: () => onCommand(RemoteCommandType.dpadLeft),
|
||||
size: size,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: size,
|
||||
child: _DPadButton(
|
||||
icon: Icons.arrow_right,
|
||||
onPressed: () => onCommand(RemoteCommandType.dpadRight),
|
||||
size: size,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: (size * 3 - centerSize) / 2,
|
||||
top: (size * 3 - centerSize) / 2,
|
||||
child: _DPadButton(
|
||||
icon: Icons.check,
|
||||
label: 'OK',
|
||||
onPressed: () => onCommand(RemoteCommandType.select),
|
||||
size: centerSize,
|
||||
isPrimary: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DPadButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String? label;
|
||||
final VoidCallback onPressed;
|
||||
final double size;
|
||||
final bool isPrimary;
|
||||
|
||||
const _DPadButton({
|
||||
required this.icon,
|
||||
this.label,
|
||||
required this.onPressed,
|
||||
required this.size,
|
||||
this.isPrimary = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
onPressed();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: const CircleBorder(),
|
||||
backgroundColor: isPrimary ? Theme.of(context).colorScheme.primary : null,
|
||||
foregroundColor: isPrimary ? Theme.of(context).colorScheme.onPrimary : null,
|
||||
),
|
||||
child: label != null
|
||||
? Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 24),
|
||||
const SizedBox(height: 2),
|
||||
Text(label!, style: const TextStyle(fontSize: 10)),
|
||||
],
|
||||
)
|
||||
: Icon(icon, size: 36),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: FilledButton(
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
onPressed();
|
||||
},
|
||||
style: FilledButton.styleFrom(
|
||||
padding: EdgeInsets.zero,
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
child: Icon(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 ActionChip(
|
||||
avatar: Icon(icon, size: 18),
|
||||
label: Text(label),
|
||||
onPressed: () {
|
||||
HapticFeedback.lightImpact();
|
||||
onPressed();
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SearchBottomSheet extends StatefulWidget {
|
||||
final CompanionRemoteProvider provider;
|
||||
|
||||
const _SearchBottomSheet({required this.provider});
|
||||
|
||||
@override
|
||||
State<_SearchBottomSheet> createState() => _SearchBottomSheetState();
|
||||
}
|
||||
|
||||
class _SearchBottomSheetState extends State<_SearchBottomSheet> {
|
||||
final _controller = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit(String text) {
|
||||
final trimmed = text.trim();
|
||||
if (trimmed.isNotEmpty) {
|
||||
widget.provider.sendCommand(RemoteCommandType.search, data: {'query': trimmed});
|
||||
}
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.viewInsetsOf(context).bottom,
|
||||
left: 16,
|
||||
right: 16,
|
||||
top: 16,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _controller,
|
||||
autofocus: true,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search on desktop...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: IconButton(
|
||||
icon: const Icon(Icons.send),
|
||||
onPressed: () => _submit(_controller.text),
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
),
|
||||
),
|
||||
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: BorderRadius.circular(12),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 32),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user