shortcut_tile.dart 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. import 'dart:typed_data';
  2. import 'package:flutter/material.dart';
  3. import '../../../core/theme/app_colors.dart';
  4. import '../../../core/widgets/app_icon.dart';
  5. import '../../../core/widgets/app_icon_image.dart';
  6. import '../../../core/widgets/pressable.dart';
  7. import 'music_equalizer.dart';
  8. /// One large, generic shortcut on the home screen.
  9. class ShortcutTile extends StatelessWidget {
  10. const ShortcutTile({
  11. super.key,
  12. required this.title,
  13. required this.subtitle,
  14. required this.iconAsset,
  15. required this.size,
  16. required this.assigned,
  17. required this.onTap,
  18. this.onLongPress,
  19. this.longPressHint,
  20. this.iconBytes,
  21. this.showEqualizer = false,
  22. });
  23. final String title;
  24. final String? subtitle;
  25. final String iconAsset;
  26. final double size;
  27. final bool assigned;
  28. final VoidCallback onTap;
  29. final VoidCallback? onLongPress;
  30. final String? longPressHint;
  31. final Uint8List? iconBytes;
  32. final bool showEqualizer;
  33. @override
  34. Widget build(BuildContext context) {
  35. final colors = context.colors;
  36. final theme = Theme.of(context);
  37. final placeholder = !assigned;
  38. final iconSize = (size * 0.27).clamp(24.0, 46.0);
  39. final badgeSize = (size * 0.18).clamp(20.0, 26.0);
  40. final titleSize = size >= 160 ? 16.0 : (size >= 110 ? 14.0 : 12.0);
  41. final subtitleSize = size >= 160 ? 12.0 : (size >= 110 ? 11.0 : 10.0);
  42. final semantics = StringBuffer(title);
  43. if (subtitle != null && subtitle!.isNotEmpty) {
  44. semantics.write(', $subtitle');
  45. }
  46. semantics.write('. Tap to open.');
  47. if (onLongPress != null) {
  48. semantics.write(' Long press to ${longPressHint ?? 'change the app'}.');
  49. }
  50. if (showEqualizer) semantics.write(' Music playing.');
  51. return Pressable(
  52. onTap: onTap,
  53. onLongPress: onLongPress,
  54. semanticLabel: semantics.toString(),
  55. child: SizedBox(
  56. width: size,
  57. child: Column(
  58. mainAxisSize: MainAxisSize.min,
  59. children: [
  60. Container(
  61. width: size,
  62. height: size,
  63. decoration: BoxDecoration(
  64. color: colors.surface,
  65. borderRadius: BorderRadius.circular(size * 0.17),
  66. border: Border.all(
  67. color: placeholder
  68. ? colors.accent.withValues(alpha: 0.16)
  69. : colors.border,
  70. ),
  71. boxShadow: [
  72. BoxShadow(
  73. color: colors.shadow.withValues(
  74. alpha: Theme.of(context).brightness == Brightness.dark
  75. ? 0.2
  76. : 0.04,
  77. ),
  78. blurRadius: 26,
  79. offset: const Offset(0, 10),
  80. ),
  81. ],
  82. ),
  83. child: Stack(
  84. children: [
  85. Center(
  86. child: iconBytes != null
  87. ? AppIconImage(
  88. bytes: iconBytes,
  89. size: (size * 0.36).clamp(28.0, 54.0),
  90. )
  91. : AppIcon(
  92. iconAsset,
  93. size: iconSize,
  94. color: placeholder ? colors.inkMuted : colors.ink,
  95. ),
  96. ),
  97. if (placeholder)
  98. Positioned(
  99. top: size * 0.09,
  100. right: size * 0.09,
  101. child: Container(
  102. width: badgeSize,
  103. height: badgeSize,
  104. decoration: BoxDecoration(
  105. color: colors.accent,
  106. shape: BoxShape.circle,
  107. ),
  108. alignment: Alignment.center,
  109. child: AppIcon(
  110. 'assets/icons/plus.svg',
  111. size: badgeSize * 0.54,
  112. color: colors.accentInk,
  113. ),
  114. ),
  115. ),
  116. if (showEqualizer)
  117. Positioned(
  118. left: 0,
  119. right: 0,
  120. bottom: size * 0.07,
  121. child: Center(child: MusicEqualizer(compact: size < 130)),
  122. ),
  123. ],
  124. ),
  125. ),
  126. const SizedBox(height: 12),
  127. Text(
  128. title,
  129. maxLines: 1,
  130. overflow: TextOverflow.ellipsis,
  131. textAlign: TextAlign.center,
  132. style: theme.textTheme.titleMedium?.copyWith(fontSize: titleSize),
  133. ),
  134. const SizedBox(height: 2),
  135. SizedBox(
  136. height: 17,
  137. child: subtitle == null
  138. ? null
  139. : Text(
  140. subtitle!,
  141. maxLines: 1,
  142. overflow: TextOverflow.ellipsis,
  143. textAlign: TextAlign.center,
  144. style: theme.textTheme.bodySmall?.copyWith(
  145. color: placeholder ? colors.accent : colors.inkMuted,
  146. fontWeight: placeholder
  147. ? FontWeight.w600
  148. : FontWeight.w400,
  149. fontSize: subtitleSize,
  150. ),
  151. ),
  152. ),
  153. ],
  154. ),
  155. ),
  156. );
  157. }
  158. }