9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
345 lines
10 KiB
Dart
345 lines
10 KiB
Dart
part of '../emoji_picker.dart';
|
|
|
|
/// Emoji per row. Matches desktop's `perLine={8}`.
|
|
const _emojiPerLine = 8;
|
|
|
|
/// Rendered glyph size. Standard and custom emoji share this so a community's
|
|
/// own emoji sit in the grid at the same weight as the native set.
|
|
const _emojiGlyphSize = 28.0;
|
|
|
|
/// Height of one grid cell. Fixed rather than derived from the cell width so
|
|
/// section offsets are exact — [_EmojiSection.extent] is what the rail scrolls
|
|
/// to, and a width-dependent row height would make it a guess.
|
|
const _cellExtent = 40.0;
|
|
|
|
/// Height of a pinned section header, padding included. Also fixed, for the
|
|
/// same reason.
|
|
const _sectionHeaderExtent = 28.0;
|
|
|
|
/// Cells divide the content width evenly with no gaps, the way emoji-mart lays
|
|
/// its grid out; the space around each glyph comes from the cell being larger
|
|
/// than the glyph.
|
|
SliverGridDelegate get _gridDelegate =>
|
|
const SliverGridDelegateWithFixedCrossAxisCount(
|
|
crossAxisCount: _emojiPerLine,
|
|
mainAxisExtent: _cellExtent,
|
|
);
|
|
|
|
/// One labelled run of tiles in the continuous grid.
|
|
///
|
|
/// Sections know their own scroll extent so the rail can jump to them without
|
|
/// the grid having been laid out — with ~1.9k emoji most sections are far
|
|
/// off-screen, so there is no render object to measure or `ensureVisible`.
|
|
class _EmojiSection {
|
|
final String id;
|
|
final String label;
|
|
final IconData icon;
|
|
final int itemCount;
|
|
final Widget Function(BuildContext context, int index) itemBuilder;
|
|
|
|
const _EmojiSection({
|
|
required this.id,
|
|
required this.label,
|
|
required this.icon,
|
|
required this.itemCount,
|
|
required this.itemBuilder,
|
|
});
|
|
|
|
int get rowCount => (itemCount / _emojiPerLine).ceil();
|
|
|
|
double get extent => _sectionHeaderExtent + rowCount * _cellExtent;
|
|
}
|
|
|
|
/// Offset of each section's header, in scroll order. Element `i` is where the
|
|
/// rail should land for section `i`.
|
|
List<double> _sectionOffsets(List<_EmojiSection> sections) {
|
|
final offsets = <double>[];
|
|
var running = 0.0;
|
|
for (final section in sections) {
|
|
offsets.add(running);
|
|
running += section.extent;
|
|
}
|
|
return offsets;
|
|
}
|
|
|
|
/// Which section owns [offset] — the one whose header is pinned right now.
|
|
int _activeSectionIndex(List<double> offsets, double offset) {
|
|
var active = 0;
|
|
for (var i = 0; i < offsets.length; i++) {
|
|
// Half a header of slack so the highlight flips as a header reaches the
|
|
// top rather than a pixel before.
|
|
if (offsets[i] <= offset + _sectionHeaderExtent / 2) active = i;
|
|
}
|
|
return active;
|
|
}
|
|
|
|
/// One tappable standard emoji.
|
|
///
|
|
/// [keyPrefix] exists because the frequently-used section repeats emoji that
|
|
/// also appear in their own category, and both are in the same scroll view — two
|
|
/// widgets keyed `emoji-tile-fire` would be indistinguishable to a test.
|
|
class _EmojiTile extends StatelessWidget {
|
|
final EmojiEntry entry;
|
|
final VoidCallback onTap;
|
|
final String keyPrefix;
|
|
|
|
const _EmojiTile({
|
|
required this.entry,
|
|
required this.onTap,
|
|
this.keyPrefix = 'emoji-tile',
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
key: ValueKey('$keyPrefix-${entry.tileId}'),
|
|
onTap: onTap,
|
|
behavior: HitTestBehavior.opaque,
|
|
child: Semantics(
|
|
button: true,
|
|
label: entry.name,
|
|
child: Center(
|
|
child: NativeEmojiGlyph(emoji: entry.native, size: _emojiGlyphSize),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// One tappable custom (image) emoji. Same cell and same glyph size as
|
|
/// [_EmojiTile] — a community's emoji shouldn't be laid out on its own looser
|
|
/// grid, which is what made the custom tab read as a different component.
|
|
class _CustomEmojiTile extends StatelessWidget {
|
|
final CustomEmoji emoji;
|
|
final VoidCallback onTap;
|
|
final String keyPrefix;
|
|
|
|
const _CustomEmojiTile({
|
|
required this.emoji,
|
|
required this.onTap,
|
|
this.keyPrefix = 'emoji-tile-custom',
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
key: ValueKey('$keyPrefix-${emoji.shortcode}'),
|
|
onTap: onTap,
|
|
behavior: HitTestBehavior.opaque,
|
|
child: Tooltip(
|
|
message: ':${emoji.shortcode}:',
|
|
child: Center(
|
|
child: CustomEmojiImage(
|
|
shortcode: emoji.shortcode,
|
|
url: emoji.url,
|
|
size: _emojiGlyphSize,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Every section in one scroll view, headers pinning as they reach the top.
|
|
///
|
|
/// Replaces the old tab-per-category swap: the rail is a shortcut into a single
|
|
/// list, not a set of separate pages, which is how both desktop and every
|
|
/// system emoji keyboard behave.
|
|
class _ContinuousEmojiGrid extends StatelessWidget {
|
|
final List<_EmojiSection> sections;
|
|
final ScrollController controller;
|
|
|
|
const _ContinuousEmojiGrid({
|
|
required this.sections,
|
|
required this.controller,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return CustomScrollView(
|
|
key: const ValueKey('emoji-picker-grid'),
|
|
controller: controller,
|
|
slivers: [
|
|
for (final section in sections)
|
|
SliverMainAxisGroup(
|
|
key: ValueKey('emoji-section-${section.id}'),
|
|
slivers: [
|
|
SliverPersistentHeader(
|
|
pinned: true,
|
|
delegate: _SectionHeaderDelegate(section.label),
|
|
),
|
|
SliverPadding(
|
|
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
|
|
sliver: SliverGrid.builder(
|
|
gridDelegate: _gridDelegate,
|
|
itemCount: section.itemCount,
|
|
itemBuilder: section.itemBuilder,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
// Clear the home indicator so the last row isn't half-swallowed. Sits
|
|
// outside the sections so it doesn't skew their offsets.
|
|
SliverToBoxAdapter(
|
|
child: SizedBox(
|
|
height: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Search results: custom emoji first (a community's own emoji are the ones a
|
|
/// user is most likely hunting for by name), then the standard set.
|
|
class _EmojiSearchResults extends StatelessWidget {
|
|
final List<EmojiEntry> entries;
|
|
final List<CustomEmoji> customEmoji;
|
|
final void Function(String emoji) onSelect;
|
|
|
|
const _EmojiSearchResults({
|
|
required this.entries,
|
|
required this.customEmoji,
|
|
required this.onSelect,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (entries.isEmpty && customEmoji.isEmpty) {
|
|
return _EmojiEmptyState(
|
|
icon: LucideIcons.searchX,
|
|
message: context.l10n.emojiNoResults,
|
|
);
|
|
}
|
|
|
|
return CustomScrollView(
|
|
key: const ValueKey('emoji-picker-search-results'),
|
|
slivers: [
|
|
if (customEmoji.isNotEmpty) ...[
|
|
_SectionHeader(label: context.l10n.emojiCustom),
|
|
SliverPadding(
|
|
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
|
|
sliver: SliverGrid.builder(
|
|
gridDelegate: _gridDelegate,
|
|
itemCount: customEmoji.length,
|
|
itemBuilder: (context, index) {
|
|
final entry = customEmoji[index];
|
|
return _CustomEmojiTile(
|
|
emoji: entry,
|
|
onTap: () => onSelect(':${entry.shortcode}:'),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
if (entries.isNotEmpty) ...[
|
|
if (customEmoji.isNotEmpty)
|
|
_SectionHeader(label: context.l10n.emojiStandard),
|
|
SliverPadding(
|
|
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
|
|
sliver: SliverGrid.builder(
|
|
gridDelegate: _gridDelegate,
|
|
itemCount: entries.length,
|
|
itemBuilder: (context, index) {
|
|
final entry = entries[index];
|
|
return _EmojiTile(
|
|
entry: entry,
|
|
onTap: () => onSelect(entry.native),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
SliverToBoxAdapter(
|
|
child: SizedBox(
|
|
height: MediaQuery.viewPaddingOf(context).bottom + Grid.xxs,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Pinned header for a section of the continuous grid. Opaque, so rows scroll
|
|
/// under it rather than showing through.
|
|
class _SectionHeaderDelegate extends SliverPersistentHeaderDelegate {
|
|
final String label;
|
|
|
|
const _SectionHeaderDelegate(this.label);
|
|
|
|
@override
|
|
double get minExtent => _sectionHeaderExtent;
|
|
|
|
@override
|
|
double get maxExtent => _sectionHeaderExtent;
|
|
|
|
@override
|
|
Widget build(
|
|
BuildContext context,
|
|
double shrinkOffset,
|
|
bool overlapsContent,
|
|
) {
|
|
return Container(
|
|
color: context.colors.surfaceContainerHighest,
|
|
alignment: Alignment.centerLeft,
|
|
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
|
|
child: Text(label, style: _sectionLabelStyle(context)),
|
|
);
|
|
}
|
|
|
|
@override
|
|
bool shouldRebuild(_SectionHeaderDelegate oldDelegate) =>
|
|
oldDelegate.label != label;
|
|
}
|
|
|
|
/// Non-pinned header, for the search results list.
|
|
class _SectionHeader extends StatelessWidget {
|
|
final String label;
|
|
|
|
const _SectionHeader({required this.label});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return SliverToBoxAdapter(
|
|
child: Container(
|
|
height: _sectionHeaderExtent,
|
|
alignment: Alignment.centerLeft,
|
|
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
|
|
child: Text(label, style: _sectionLabelStyle(context)),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
TextStyle? _sectionLabelStyle(BuildContext context) =>
|
|
context.textTheme.labelMedium?.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
fontWeight: FontWeight.w600,
|
|
);
|
|
|
|
class _EmojiEmptyState extends StatelessWidget {
|
|
final IconData icon;
|
|
final String message;
|
|
|
|
const _EmojiEmptyState({required this.icon, required this.message});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: Grid.md, color: context.colors.onSurfaceVariant),
|
|
const SizedBox(height: Grid.xxs),
|
|
Text(
|
|
message,
|
|
style: context.textTheme.bodySmall?.copyWith(
|
|
color: context.colors.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|