fix(todo): comments handled as Todoist's, and the emoji sheet (ADR 0033, D38)
After the developer's recording of Todoist: the bar opens the comments to write at once, Show all to read; the heading folds them; Show all and the latest comment are boxes of their own; a comment shows who wrote it; the field grows with what wraps, which a long line didn't. The emoji sheet is written here on emoji_picker_flutter's data: its own picker has a tab bar on the SDK's Material, which found no localizations in Todo, so reacting showed an error. Todo gets the compatibility bridge, as Learn has, since the editor still imports that library. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
a25cca12b4
commit
c55e94dd99
11 changed files with 926 additions and 214 deletions
|
|
@ -59,6 +59,17 @@
|
|||
"commentReact": "Reaktion hinzufügen",
|
||||
"commentReactionRemove": "{emoji} entfernen",
|
||||
"commentNone": "Halte hier Notizen, Links oder anderes fest.",
|
||||
"emojiSearch": "Emoji suchen",
|
||||
"emojiNone": "Kein Emoji gefunden",
|
||||
"emojiRecent": "Zuletzt verwendet",
|
||||
"emojiSmileys": "Smileys & Personen",
|
||||
"emojiAnimals": "Tiere & Natur",
|
||||
"emojiFoods": "Essen & Trinken",
|
||||
"emojiActivities": "Aktivitäten",
|
||||
"emojiTravel": "Reisen & Orte",
|
||||
"emojiObjects": "Objekte",
|
||||
"emojiSymbols": "Symbole",
|
||||
"emojiFlags": "Flaggen",
|
||||
"commentsTitle": "Kommentare",
|
||||
"commentAdd": "Kommentar schreiben",
|
||||
"commentSend": "Kommentar senden",
|
||||
|
|
|
|||
|
|
@ -126,6 +126,17 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"emojiSearch": "Search emoji",
|
||||
"emojiNone": "No emoji found",
|
||||
"emojiRecent": "Recently used",
|
||||
"emojiSmileys": "Smileys & people",
|
||||
"emojiAnimals": "Animals & nature",
|
||||
"emojiFoods": "Food & drink",
|
||||
"emojiActivities": "Activities",
|
||||
"emojiTravel": "Travel & places",
|
||||
"emojiObjects": "Objects",
|
||||
"emojiSymbols": "Symbols",
|
||||
"emojiFlags": "Flags",
|
||||
"commentsTitle": "Comments",
|
||||
"commentAdd": "Add a comment",
|
||||
"commentSend": "Send comment",
|
||||
|
|
|
|||
|
|
@ -454,6 +454,72 @@ abstract class AppLocalizations {
|
|||
/// **'Add relevant notes, links or anything else here.'**
|
||||
String get commentNone;
|
||||
|
||||
/// No description provided for @emojiSearch.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Search emoji'**
|
||||
String get emojiSearch;
|
||||
|
||||
/// No description provided for @emojiNone.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No emoji found'**
|
||||
String get emojiNone;
|
||||
|
||||
/// No description provided for @emojiRecent.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Recently used'**
|
||||
String get emojiRecent;
|
||||
|
||||
/// No description provided for @emojiSmileys.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Smileys & people'**
|
||||
String get emojiSmileys;
|
||||
|
||||
/// No description provided for @emojiAnimals.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Animals & nature'**
|
||||
String get emojiAnimals;
|
||||
|
||||
/// No description provided for @emojiFoods.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Food & drink'**
|
||||
String get emojiFoods;
|
||||
|
||||
/// No description provided for @emojiActivities.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Activities'**
|
||||
String get emojiActivities;
|
||||
|
||||
/// No description provided for @emojiTravel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Travel & places'**
|
||||
String get emojiTravel;
|
||||
|
||||
/// No description provided for @emojiObjects.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Objects'**
|
||||
String get emojiObjects;
|
||||
|
||||
/// No description provided for @emojiSymbols.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Symbols'**
|
||||
String get emojiSymbols;
|
||||
|
||||
/// No description provided for @emojiFlags.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Flags'**
|
||||
String get emojiFlags;
|
||||
|
||||
/// No description provided for @commentsTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
|
|||
|
|
@ -217,6 +217,39 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get commentNone => 'Halte hier Notizen, Links oder anderes fest.';
|
||||
|
||||
@override
|
||||
String get emojiSearch => 'Emoji suchen';
|
||||
|
||||
@override
|
||||
String get emojiNone => 'Kein Emoji gefunden';
|
||||
|
||||
@override
|
||||
String get emojiRecent => 'Zuletzt verwendet';
|
||||
|
||||
@override
|
||||
String get emojiSmileys => 'Smileys & Personen';
|
||||
|
||||
@override
|
||||
String get emojiAnimals => 'Tiere & Natur';
|
||||
|
||||
@override
|
||||
String get emojiFoods => 'Essen & Trinken';
|
||||
|
||||
@override
|
||||
String get emojiActivities => 'Aktivitäten';
|
||||
|
||||
@override
|
||||
String get emojiTravel => 'Reisen & Orte';
|
||||
|
||||
@override
|
||||
String get emojiObjects => 'Objekte';
|
||||
|
||||
@override
|
||||
String get emojiSymbols => 'Symbole';
|
||||
|
||||
@override
|
||||
String get emojiFlags => 'Flaggen';
|
||||
|
||||
@override
|
||||
String get commentsTitle => 'Kommentare';
|
||||
|
||||
|
|
|
|||
|
|
@ -217,6 +217,39 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get commentNone => 'Add relevant notes, links or anything else here.';
|
||||
|
||||
@override
|
||||
String get emojiSearch => 'Search emoji';
|
||||
|
||||
@override
|
||||
String get emojiNone => 'No emoji found';
|
||||
|
||||
@override
|
||||
String get emojiRecent => 'Recently used';
|
||||
|
||||
@override
|
||||
String get emojiSmileys => 'Smileys & people';
|
||||
|
||||
@override
|
||||
String get emojiAnimals => 'Animals & nature';
|
||||
|
||||
@override
|
||||
String get emojiFoods => 'Food & drink';
|
||||
|
||||
@override
|
||||
String get emojiActivities => 'Activities';
|
||||
|
||||
@override
|
||||
String get emojiTravel => 'Travel & places';
|
||||
|
||||
@override
|
||||
String get emojiObjects => 'Objects';
|
||||
|
||||
@override
|
||||
String get emojiSymbols => 'Symbols';
|
||||
|
||||
@override
|
||||
String get emojiFlags => 'Flags';
|
||||
|
||||
@override
|
||||
String get commentsTitle => 'Comments';
|
||||
|
||||
|
|
|
|||
|
|
@ -93,30 +93,38 @@ class _TodoPersonAppState() extends State<TodoPersonApp> {
|
|||
// How many accounts wait, for whoever operates the instance (Settings'
|
||||
// operator card and the badge on Browse and its cog), and where sync
|
||||
// stands.
|
||||
builder: (context, child) => PersonScope(
|
||||
person: widget.person,
|
||||
child: MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (context) =>
|
||||
OperatingCubit(AppScope.of(context).instanceOperators),
|
||||
// Packages that still import package:flutter/material.dart (the
|
||||
// rich-text editor among them, D38) see this app's theme and
|
||||
// localizations only through the bridge, as in Learn: material_ui is a
|
||||
// separate copy of that library. Deprecated on purpose, as a reminder:
|
||||
// remove it once they have moved.
|
||||
// ignore: deprecated_member_use
|
||||
builder: (context, child) => MaterialUiCompatibilityBridge(
|
||||
child: PersonScope(
|
||||
person: widget.person,
|
||||
child: MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create: (context) =>
|
||||
OperatingCubit(AppScope.of(context).instanceOperators),
|
||||
),
|
||||
// The person's projects, tasks and labels, for every tab and
|
||||
// sheet.
|
||||
BlocProvider(
|
||||
create: (context) =>
|
||||
TodoCubit(PersonScope.of<TodoPerson>(context))..open(),
|
||||
),
|
||||
// Where sync stands: Settings' card and the dot on the cog.
|
||||
BlocProvider(
|
||||
create: (context) =>
|
||||
SyncStatusBloc(PersonScope.of<TodoPerson>(context).sync),
|
||||
),
|
||||
],
|
||||
child: ScreenColor(
|
||||
color: PaletteColor.strawberry.background,
|
||||
// Their tasks' reminders on this device (ADR 0033, D5).
|
||||
child: ReminderScheduler(child: child!),
|
||||
),
|
||||
// The person's projects, tasks and labels, for every tab and
|
||||
// sheet.
|
||||
BlocProvider(
|
||||
create: (context) =>
|
||||
TodoCubit(PersonScope.of<TodoPerson>(context))..open(),
|
||||
),
|
||||
// Where sync stands: Settings' card and the dot on the cog.
|
||||
BlocProvider(
|
||||
create: (context) =>
|
||||
SyncStatusBloc(PersonScope.of<TodoPerson>(context).sync),
|
||||
),
|
||||
],
|
||||
child: ScreenColor(
|
||||
color: PaletteColor.strawberry.background,
|
||||
// Their tasks' reminders on this device (ADR 0033, D5).
|
||||
child: ReminderScheduler(child: child!),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ class const NoteComposer({
|
|||
final bool autofocus = false,
|
||||
final FocusNode? focusNode,
|
||||
final int maxLines = 6,
|
||||
|
||||
/// The lines it keeps open while it has the focus.
|
||||
final int linesWhileWriting = 1,
|
||||
super.key,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
|
|
@ -56,18 +59,38 @@ class _NoteComposerState() extends State<NoteComposer> {
|
|||
}
|
||||
}
|
||||
|
||||
/// The lines the note takes in [width], wrapped: a long line typed into
|
||||
/// one that doesn't grow shows its end alone.
|
||||
int _linesIn(BuildContext context, double width) {
|
||||
final painter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: _note.plainText,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
),
|
||||
textDirection: Directionality.of(context),
|
||||
)..layout(maxWidth: width);
|
||||
final lines = painter.computeLineMetrics().length;
|
||||
painter.dispose();
|
||||
return math.max(1, lines);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final lines = math.max(1, _note.plainText.split('\n').length);
|
||||
// Desktops have the editor's own placeholder at the caret.
|
||||
final mobile =
|
||||
defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS;
|
||||
return AnimatedSize(
|
||||
duration: Durations.short2,
|
||||
child: SizedBox(
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => SizedBox(
|
||||
height:
|
||||
math.min(lines, widget.maxLines) * _lineHeight +
|
||||
math.min(
|
||||
math.max(
|
||||
_focused ? widget.linesWhileWriting : 1,
|
||||
_linesIn(context, constraints.maxWidth),
|
||||
),
|
||||
widget.maxLines,
|
||||
) *
|
||||
_lineHeight +
|
||||
8 +
|
||||
(_focused ? _barHeight : 0),
|
||||
child: Stack(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,309 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'
|
||||
show Category, CategoryEmoji, Emoji, EmojiPickerState, EmojiPickerUtils;
|
||||
import 'package:emoji_picker_flutter/locales/default_emoji_set_locale.dart';
|
||||
import 'package:halkyon_todo_flutter/l10n/app_localizations.dart';
|
||||
import 'package:halkyon_ui/halkyon_ui.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:material_ui/material_ui.dart';
|
||||
|
||||
/// Every emoji to pick from (ADR 0033, D38, as Todoist's): the search above,
|
||||
/// the recently used first, then each category under its name, all in one
|
||||
/// list, with a bar to jump between them. Null where none was picked.
|
||||
///
|
||||
/// `emoji_picker_flutter` gives the emoji, their names in each language and
|
||||
/// the recents, which stay on this device; its own picker has its search at
|
||||
/// the foot and a tab bar on the SDK's Material.
|
||||
Future<String?> pickEmoji(BuildContext context) => showAdaptiveSheet<String>(
|
||||
context,
|
||||
builder: (context) => const _EmojiSheet(),
|
||||
);
|
||||
|
||||
/// What shows as recently used until as many have been: Todoist's.
|
||||
const _usual = [
|
||||
'👍', '❤️', '👏', '😂', '🎉', '😮', //
|
||||
'😢', '👎', '👀', '✅', '👋', '🙏',
|
||||
];
|
||||
|
||||
const _cellWidth = 48.0;
|
||||
const _headerHeight = 36.0;
|
||||
|
||||
class const _EmojiSheet() extends StatefulWidget {
|
||||
@override
|
||||
State<_EmojiSheet> createState() => _EmojiSheetState();
|
||||
}
|
||||
|
||||
class _EmojiSheetState() extends State<_EmojiSheet> {
|
||||
final _utils = EmojiPickerUtils();
|
||||
final _scroll = ScrollController();
|
||||
final _search = TextEditingController();
|
||||
List<String> _recent = _usual;
|
||||
List<CategoryEmoji> _sets = const [];
|
||||
List<Emoji>? _found;
|
||||
var _columns = 1;
|
||||
var _cell = _cellWidth;
|
||||
var _shown = Category.RECENT;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scroll.addListener(_onScroll);
|
||||
unawaited(_loadRecent());
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
unawaited(_loadSets());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scroll.dispose();
|
||||
_search.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadRecent() async {
|
||||
try {
|
||||
final recent = await _utils.getRecentEmojis();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_recent = {
|
||||
for (final used in recent) used.emoji.emoji,
|
||||
..._usual,
|
||||
}.take(_usual.length).toList();
|
||||
});
|
||||
}
|
||||
} on Object {
|
||||
// Without what the device kept, the usual ones show.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadSets() async {
|
||||
final all = getDefaultEmojiLocale(Localizations.localeOf(context));
|
||||
List<CategoryEmoji> sets;
|
||||
try {
|
||||
sets = await _utils.filterUnsupported(all);
|
||||
} on Object {
|
||||
sets = all;
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_sets = [
|
||||
for (final set in sets)
|
||||
if (set.category != Category.RECENT) set,
|
||||
];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
double _sectionHeight(int count) =>
|
||||
_headerHeight + (count / _columns).ceil() * _cell;
|
||||
|
||||
/// Where [category] starts in the list.
|
||||
double _offsetOf(Category category) {
|
||||
var offset = _sectionHeight(_recent.length);
|
||||
if (category == Category.RECENT) {
|
||||
return 0;
|
||||
}
|
||||
for (final set in _sets) {
|
||||
if (set.category == category) {
|
||||
return offset;
|
||||
}
|
||||
offset += _sectionHeight(set.emoji.length);
|
||||
}
|
||||
return offset;
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
final at = _scroll.offset + 1;
|
||||
var shown = Category.RECENT;
|
||||
for (final set in _sets) {
|
||||
if (_offsetOf(set.category) > at) {
|
||||
break;
|
||||
}
|
||||
shown = set.category;
|
||||
}
|
||||
if (shown != _shown) {
|
||||
setState(() => _shown = shown);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSearch(String text) async {
|
||||
final query = text.trim();
|
||||
final found = query.isEmpty
|
||||
? null
|
||||
: await _utils.searchEmoji(
|
||||
query,
|
||||
_sets,
|
||||
checkPlatformCompatibility: false,
|
||||
);
|
||||
if (mounted && _search.text.trim() == query) {
|
||||
setState(() => _found = found);
|
||||
}
|
||||
}
|
||||
|
||||
void _pick(Emoji emoji) {
|
||||
unawaited(_remember(emoji));
|
||||
Navigator.of(context).pop(emoji.emoji);
|
||||
}
|
||||
|
||||
Future<void> _remember(Emoji emoji) async {
|
||||
try {
|
||||
await _utils.addEmojiToRecentlyUsed(
|
||||
key: GlobalKey<EmojiPickerState>(),
|
||||
emoji: emoji,
|
||||
);
|
||||
} on Object {
|
||||
// The reaction counts; only the recents miss it.
|
||||
}
|
||||
}
|
||||
|
||||
String _nameOf(AppLocalizations l10n, Category category) =>
|
||||
switch (category) {
|
||||
Category.RECENT => l10n.emojiRecent,
|
||||
Category.SMILEYS => l10n.emojiSmileys,
|
||||
Category.ANIMALS => l10n.emojiAnimals,
|
||||
Category.FOODS => l10n.emojiFoods,
|
||||
Category.ACTIVITIES => l10n.emojiActivities,
|
||||
Category.TRAVEL => l10n.emojiTravel,
|
||||
Category.OBJECTS => l10n.emojiObjects,
|
||||
Category.SYMBOLS => l10n.emojiSymbols,
|
||||
Category.FLAGS => l10n.emojiFlags,
|
||||
};
|
||||
|
||||
IconData _iconOf(Category category) => switch (category) {
|
||||
Category.RECENT => LucideIcons.clock,
|
||||
Category.SMILEYS => LucideIcons.smile,
|
||||
Category.ANIMALS => LucideIcons.pawPrint,
|
||||
Category.FOODS => LucideIcons.apple,
|
||||
Category.ACTIVITIES => LucideIcons.trophy,
|
||||
Category.TRAVEL => LucideIcons.car,
|
||||
Category.OBJECTS => LucideIcons.lightbulb,
|
||||
Category.SYMBOLS => LucideIcons.hash,
|
||||
Category.FLAGS => LucideIcons.flag,
|
||||
};
|
||||
|
||||
Widget _grid(List<Emoji> emoji) => SliverGrid.builder(
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: _columns,
|
||||
),
|
||||
itemCount: emoji.length,
|
||||
itemBuilder: (context, index) => InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: () => _pick(emoji[index]),
|
||||
child: Center(
|
||||
child: Text(emoji[index].emoji, style: const TextStyle(fontSize: 26)),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _header(ThemeData theme, String name) => SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: _headerHeight,
|
||||
child: Align(
|
||||
alignment: AlignmentDirectional.centerStart,
|
||||
child: Text(
|
||||
name.toUpperCase(),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final found = _found;
|
||||
return SheetScaffold(
|
||||
title: l10n.commentReact,
|
||||
closeButton: sheetShowsAsDialog(context),
|
||||
heading: TextField(
|
||||
controller: _search,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
prefixIcon: const Icon(LucideIcons.search),
|
||||
hintText: l10n.emojiSearch,
|
||||
),
|
||||
onChanged: (text) => unawaited(_onSearch(text)),
|
||||
),
|
||||
body: SizedBox(
|
||||
height: math.min(420, MediaQuery.sizeOf(context).height * 0.5),
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
_columns = math.max(
|
||||
1,
|
||||
(constraints.maxWidth / _cellWidth).floor(),
|
||||
);
|
||||
_cell = constraints.maxWidth / _columns;
|
||||
return CustomScrollView(
|
||||
controller: _scroll,
|
||||
slivers: found != null
|
||||
? [
|
||||
_grid(found),
|
||||
if (found.isEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Text(
|
||||
l10n.emojiNone,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
: [
|
||||
_header(theme, l10n.emojiRecent),
|
||||
_grid([
|
||||
for (final emoji in _recent) Emoji(emoji, ''),
|
||||
]),
|
||||
for (final set in _sets) ...[
|
||||
_header(theme, _nameOf(l10n, set.category)),
|
||||
_grid(set.emoji),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
if (found == null)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
for (final category in [
|
||||
Category.RECENT,
|
||||
for (final set in _sets) set.category,
|
||||
])
|
||||
Flexible(
|
||||
child: IconButton(
|
||||
tooltip: _nameOf(l10n, category),
|
||||
isSelected: category == _shown,
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: Icon(_iconOf(category), size: 20),
|
||||
onPressed: () => _scroll.animateTo(
|
||||
math.min(
|
||||
_offsetOf(category),
|
||||
_scroll.position.maxScrollExtent,
|
||||
),
|
||||
duration: Durations.medium2,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:halkyon_app/halkyon_app.dart';
|
||||
|
|
@ -12,6 +11,7 @@ import 'package:halkyon_todo_flutter/src/core/note/note.dart';
|
|||
import 'package:halkyon_todo_flutter/src/core/note/note_fields.dart';
|
||||
import 'package:halkyon_todo_flutter/src/core/widget/menu_entry.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/comment/model/comment.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/comment/widget/emoji_sheet.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/bloc/todo_cubit.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/model/task.dart';
|
||||
import 'package:halkyon_todo_flutter/src/feature/task/widget/task_texts.dart';
|
||||
|
|
@ -25,6 +25,12 @@ const commentFieldKey = ValueKey('comment-field');
|
|||
/// The key of the button sending a comment.
|
||||
const commentSendKey = ValueKey('comment-send');
|
||||
|
||||
/// The heading over a task's comments, folding them.
|
||||
const previewHeadingKey = ValueKey('comments-heading');
|
||||
|
||||
/// The task's name on its comments' sheet, leading back to it.
|
||||
const commentsBackKey = ValueKey('comments-back');
|
||||
|
||||
/// The bar at the foot of a task's sheet (ADR 0033, D36, as Todoist's),
|
||||
/// which stays while the task scrolls and opens its comments to write one.
|
||||
class const AddCommentBar({required final String taskId, super.key})
|
||||
|
|
@ -45,9 +51,9 @@ class const AddCommentBar({required final String taskId, super.key})
|
|||
shape: const StadiumBorder(),
|
||||
child: InkWell(
|
||||
key: barKey,
|
||||
onTap: () => unawaited(showComments(context, taskId)),
|
||||
onTap: () => unawaited(showComments(context, taskId, write: true)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
child: Text(
|
||||
l10n.commentAdd,
|
||||
style: TextStyle(color: theme.colorScheme.onSurfaceVariant),
|
||||
|
|
@ -60,64 +66,169 @@ class const AddCommentBar({required final String taskId, super.key})
|
|||
}
|
||||
}
|
||||
|
||||
/// What a task's sheet shows of its comments (as Todoist's): how many, the
|
||||
/// latest on one line, and Show all opening them.
|
||||
/// A box of a task's comments: a comment's, or Show all's.
|
||||
Widget _box(
|
||||
BuildContext context, {
|
||||
required Widget child,
|
||||
VoidCallback? onTap,
|
||||
}) => Material(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: InkWell(onTap: onTap, child: child),
|
||||
);
|
||||
|
||||
/// What a task's sheet shows of its comments (as Todoist's): how many, with
|
||||
/// a chevron folding them, the latest in its box on one line, and Show all;
|
||||
/// nothing without any, the bar at the foot being there.
|
||||
class const CommentsPreview({required final String taskId, super.key})
|
||||
extends StatelessWidget {
|
||||
extends StatefulWidget {
|
||||
@override
|
||||
State<CommentsPreview> createState() => _CommentsPreviewState();
|
||||
}
|
||||
|
||||
class _CommentsPreviewState() extends State<CommentsPreview> {
|
||||
var _open = true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
final comments = context.watch<TodoCubit>().state.commentsOn(taskId);
|
||||
final comments = context.watch<TodoCubit>().state.commentsOn(widget.taskId);
|
||||
if (comments.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return InkWell(
|
||||
onTap: () => unawaited(showComments(context, taskId)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'${l10n.commentsTitle} ${comments.length}',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
void show() => unawaited(showComments(context, widget.taskId));
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
spacing: 4,
|
||||
children: [
|
||||
InkWell(
|
||||
key: previewHeadingKey,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () => setState(() => _open = !_open),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${l10n.commentsTitle} ${comments.length}',
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
_open ? LucideIcons.chevronUp : LucideIcons.chevronDown,
|
||||
size: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_open) ...[
|
||||
_box(
|
||||
context,
|
||||
onTap: show,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
spacing: 12,
|
||||
children: [
|
||||
const _Avatar(radius: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
comments.last.content!.text.plainText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
comments.last.content!.text.plainText,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (comments.length > 1)
|
||||
Align(
|
||||
child: TextButton(
|
||||
onPressed: () => unawaited(showComments(context, taskId)),
|
||||
child: Text(l10n.commentShowAll),
|
||||
),
|
||||
_box(
|
||||
context,
|
||||
onTap: show,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Text(
|
||||
l10n.commentShowAll,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The comments on the task [taskId] on a sheet of their own (D36, as
|
||||
/// Todoist's): each in its box, the latest in view, and the field to write
|
||||
/// one more at the foot, ready, whatever has scrolled.
|
||||
Future<void> showComments(BuildContext context, String taskId) =>
|
||||
showAdaptiveSheet<void>(
|
||||
context,
|
||||
builder: (context) => _CommentsSheet(taskId: taskId),
|
||||
);
|
||||
/// The person's own profile, asked once a session; null without one, as
|
||||
/// offline. Who wrote a comment isn't kept: its one writer today is them.
|
||||
final _mine = Expando<Future<PublicProfile?>>();
|
||||
|
||||
class const _CommentsSheet({required final String taskId})
|
||||
extends StatefulWidget {
|
||||
Future<PublicProfile?> _profileOf(BuildContext context) {
|
||||
final profiles = AppScope.of(context).profiles;
|
||||
return _mine[PersonScope.of<TodoPerson>(context)] ??= () async {
|
||||
try {
|
||||
return await profiles.mine();
|
||||
} on Object {
|
||||
return null;
|
||||
}
|
||||
}();
|
||||
}
|
||||
|
||||
/// The writer's picture, or their name's first letter on a circle.
|
||||
class const _Avatar({final double radius = 14}) extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => FutureBuilder(
|
||||
future: _profileOf(context),
|
||||
builder: (context, snapshot) {
|
||||
final profile = snapshot.data;
|
||||
final name = _nameOf(profile);
|
||||
return CircleAvatar(
|
||||
radius: radius,
|
||||
foregroundImage: switch (profile?.avatarUrl) {
|
||||
final url? => NetworkImage(url),
|
||||
null => null,
|
||||
},
|
||||
child: name.isEmpty
|
||||
? Icon(LucideIcons.user, size: radius)
|
||||
: Text(
|
||||
name.substring(0, 1).toUpperCase(),
|
||||
style: TextStyle(fontSize: radius),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _nameOf(PublicProfile? profile) => switch (profile) {
|
||||
null => '',
|
||||
PublicProfile(:final displayName) when displayName.isNotEmpty => displayName,
|
||||
PublicProfile(:final handle) => handle,
|
||||
};
|
||||
|
||||
/// The comments on the task [taskId] on a sheet of their own (D36, as
|
||||
/// Todoist's), over the task's: each in its box, the latest in view, the
|
||||
/// field at the foot, ready to write with the keyboard open where [write]
|
||||
/// says so, a bar to tap where not.
|
||||
Future<void> showComments(
|
||||
BuildContext context,
|
||||
String taskId, {
|
||||
bool write = false,
|
||||
}) => showAdaptiveSheet<void>(
|
||||
context,
|
||||
builder: (context) => _CommentsSheet(taskId: taskId, write: write),
|
||||
);
|
||||
|
||||
class const _CommentsSheet({
|
||||
required final String taskId,
|
||||
required final bool write,
|
||||
}) extends StatefulWidget {
|
||||
@override
|
||||
State<_CommentsSheet> createState() => _CommentsSheetState();
|
||||
}
|
||||
|
|
@ -126,13 +237,23 @@ class _CommentsSheetState() extends State<_CommentsSheet> {
|
|||
var _draft = RichContent.empty();
|
||||
var _sent = 0;
|
||||
final _last = GlobalKey();
|
||||
final _focus = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_focus.addListener(_onFocus);
|
||||
_showLatest();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focus.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onFocus() => setState(() {});
|
||||
|
||||
void _showLatest() => WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_last.currentContext case final context? when context.mounted) {
|
||||
Scrollable.ensureVisible(context);
|
||||
|
|
@ -173,16 +294,29 @@ class _CommentsSheetState() extends State<_CommentsSheet> {
|
|||
.title;
|
||||
return SheetScaffold(
|
||||
title: l10n.commentsTitle,
|
||||
closeButton: sheetShowsAsDialog(context),
|
||||
heading: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.commentsTitle, style: theme.textTheme.titleLarge),
|
||||
if (title != null)
|
||||
Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: muted),
|
||||
InkWell(
|
||||
key: commentsBackKey,
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: muted),
|
||||
),
|
||||
),
|
||||
Icon(LucideIcons.chevronRight, size: 14, color: muted),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -191,11 +325,11 @@ class _CommentsSheetState() extends State<_CommentsSheet> {
|
|||
children: [
|
||||
if (comments.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
|
||||
child: Text(
|
||||
l10n.commentNone,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: muted),
|
||||
style: theme.textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
for (final (index, comment) in comments.indexed)
|
||||
|
|
@ -210,7 +344,7 @@ class _CommentsSheetState() extends State<_CommentsSheet> {
|
|||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 4, 4),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 4, 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
|
|
@ -221,17 +355,20 @@ class _CommentsSheetState() extends State<_CommentsSheet> {
|
|||
key: commentFieldKey,
|
||||
initial: null,
|
||||
hint: l10n.commentAdd,
|
||||
autofocus: true,
|
||||
focusNode: _focus,
|
||||
autofocus: widget.write || _sent > 0,
|
||||
linesWhileWriting: 2,
|
||||
onChanged: (note) => setState(() => _draft = note),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton.filled(
|
||||
key: commentSendKey,
|
||||
tooltip: l10n.commentSend,
|
||||
icon: const Icon(LucideIcons.arrowUp),
|
||||
onPressed: _draft.isBlank ? null : () => unawaited(_send(todo)),
|
||||
),
|
||||
if (_focus.hasFocus || !_draft.isBlank)
|
||||
IconButton.filled(
|
||||
key: commentSendKey,
|
||||
tooltip: l10n.commentSend,
|
||||
icon: const Icon(LucideIcons.arrowUp),
|
||||
onPressed: _draft.isBlank ? null : () => unawaited(_send(todo)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -239,8 +376,8 @@ class _CommentsSheetState() extends State<_CommentsSheet> {
|
|||
}
|
||||
}
|
||||
|
||||
/// A comment in a box of its own (D36, as Todoist's), set off from the next:
|
||||
/// when it was written, its menu, its text and its reactions.
|
||||
/// A comment in a box of its own (D36, as Todoist's): who wrote it and when,
|
||||
/// its menu, then its text and its reactions under the name.
|
||||
class const _Comment({required final StoredComment comment, super.key})
|
||||
extends StatelessWidget {
|
||||
Future<void> _edit(BuildContext context, CommentContent content) async {
|
||||
|
|
@ -279,100 +416,153 @@ class const _Comment({required final StoredComment comment, super.key})
|
|||
final todo = PersonScope.of<TodoPerson>(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Material(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: _box(
|
||||
context,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 4, 12),
|
||||
child: Column(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 4, 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
spacing: 10,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
content.editedAt == null
|
||||
? when
|
||||
: l10n.commentEdited(when),
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: muted),
|
||||
),
|
||||
),
|
||||
PopupMenuButton<VoidCallback>(
|
||||
tooltip: l10n.commentMenu,
|
||||
icon: const Icon(LucideIcons.ellipsis),
|
||||
onSelected: (action) => action(),
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: () => unawaited(_react(context, content)),
|
||||
child: MenuEntry(
|
||||
LucideIcons.smilePlus,
|
||||
l10n.commentReact,
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: () => unawaited(
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: content.text.plainText),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 8),
|
||||
child: _Avatar(),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FutureBuilder(
|
||||
future: _profileOf(context),
|
||||
builder: (context, snapshot) {
|
||||
final name = _nameOf(snapshot.data);
|
||||
return Text.rich(
|
||||
TextSpan(
|
||||
children: [
|
||||
if (name.isNotEmpty)
|
||||
TextSpan(
|
||||
text: '$name ',
|
||||
style: theme.textTheme.labelLarge,
|
||||
),
|
||||
TextSpan(
|
||||
text: content.editedAt == null
|
||||
? when
|
||||
: l10n.commentEdited(when),
|
||||
style: theme.textTheme.bodySmall
|
||||
?.copyWith(color: muted),
|
||||
),
|
||||
],
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
child: MenuEntry(LucideIcons.copy, l10n.commentCopy),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: () => unawaited(_edit(context, content)),
|
||||
child: MenuEntry(LucideIcons.pencil, l10n.commentEdit),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: () async {
|
||||
if (await confirmDestructiveAction(
|
||||
context,
|
||||
title: l10n.commentDeleteTitle,
|
||||
message: content.text.plainText,
|
||||
confirmLabel: l10n.commentDelete,
|
||||
)) {
|
||||
await todo.comments.delete([comment.id]);
|
||||
}
|
||||
},
|
||||
child: MenuEntry(
|
||||
LucideIcons.trash2,
|
||||
l10n.commentDelete,
|
||||
destructive: true,
|
||||
PopupMenuButton<VoidCallback>(
|
||||
tooltip: l10n.commentMenu,
|
||||
icon: const Icon(LucideIcons.ellipsis),
|
||||
onSelected: (action) => action(),
|
||||
itemBuilder: (context) => [
|
||||
PopupMenuItem(
|
||||
value: () => unawaited(_react(context, content)),
|
||||
child: MenuEntry(
|
||||
LucideIcons.smilePlus,
|
||||
l10n.commentReact,
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: () => unawaited(
|
||||
Clipboard.setData(
|
||||
ClipboardData(text: content.text.plainText),
|
||||
),
|
||||
),
|
||||
child: MenuEntry(
|
||||
LucideIcons.copy,
|
||||
l10n.commentCopy,
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: () => unawaited(_edit(context, content)),
|
||||
child: MenuEntry(
|
||||
LucideIcons.pencil,
|
||||
l10n.commentEdit,
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: () async {
|
||||
if (await confirmDestructiveAction(
|
||||
context,
|
||||
title: l10n.commentDeleteTitle,
|
||||
message: content.text.plainText,
|
||||
confirmLabel: l10n.commentDelete,
|
||||
)) {
|
||||
await todo.comments.delete([comment.id]);
|
||||
}
|
||||
},
|
||||
child: MenuEntry(
|
||||
LucideIcons.trash2,
|
||||
l10n.commentDelete,
|
||||
destructive: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 12),
|
||||
child: RichContentView(content: content.text),
|
||||
),
|
||||
if (content.reactions.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
for (final emoji in content.reactions)
|
||||
Tooltip(
|
||||
message: l10n.commentReactionRemove(emoji),
|
||||
child: Material(
|
||||
color: theme.colorScheme.primaryContainer,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
shape: StadiumBorder(
|
||||
side: BorderSide(
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => todo.comments.update(
|
||||
comment.id,
|
||||
content.reactedWith(emoji),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Text('$emoji 1'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: l10n.commentReact,
|
||||
icon: const Icon(LucideIcons.smilePlus, size: 18),
|
||||
onPressed: () =>
|
||||
unawaited(_react(context, content)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsetsDirectional.only(end: 12),
|
||||
child: RichContentView(content: content.text),
|
||||
),
|
||||
if (content.reactions.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
for (final emoji in content.reactions)
|
||||
ActionChip(
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: l10n.commentReactionRemove(emoji),
|
||||
label: Text('$emoji 1'),
|
||||
onPressed: () => todo.comments.update(
|
||||
comment.id,
|
||||
content.reactedWith(emoji),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
tooltip: l10n.commentReact,
|
||||
icon: const Icon(LucideIcons.smilePlus, size: 18),
|
||||
onPressed: () => unawaited(_react(context, content)),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
|
@ -380,36 +570,3 @@ class const _Comment({required final StoredComment comment, super.key})
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every emoji to pick from, recents first and searchable, as Todoist's
|
||||
/// (D36); null where none was picked. The recents stay on this device.
|
||||
Future<String?> pickEmoji(BuildContext context) => showAdaptiveSheet<String>(
|
||||
context,
|
||||
builder: (context) {
|
||||
final l10n = AppLocalizations.of(context);
|
||||
final theme = Theme.of(context);
|
||||
return SheetScaffold(
|
||||
title: l10n.commentReact,
|
||||
body: SizedBox(
|
||||
height: 360,
|
||||
child: EmojiPicker(
|
||||
onEmojiSelected: (_, emoji) => Navigator.of(context).pop(emoji.emoji),
|
||||
config: Config(
|
||||
locale: Localizations.localeOf(context),
|
||||
bottomActionBarConfig: const BottomActionBarConfig(
|
||||
showBackspaceButton: false,
|
||||
),
|
||||
emojiViewConfig: EmojiViewConfig(
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
),
|
||||
categoryViewConfig: CategoryViewConfig(
|
||||
backgroundColor: theme.colorScheme.surface,
|
||||
indicatorColor: theme.colorScheme.primary,
|
||||
iconColorSelected: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -912,8 +912,7 @@ class _TaskSheetState() extends State<_TaskSheet> {
|
|||
],
|
||||
),
|
||||
),
|
||||
if (state.commentsOn(task.id).isNotEmpty)
|
||||
_Box(child: CommentsPreview(taskId: task.id)),
|
||||
CommentsPreview(taskId: task.id),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/gestures.dart'
|
||||
show PointerDeviceKind, kLongPressTimeout;
|
||||
|
|
@ -607,7 +606,7 @@ void main() {
|
|||
await openComments(tester);
|
||||
await write(tester, 'First note');
|
||||
await write(tester, 'Second note');
|
||||
await tester.tap(find.byTooltip('Close').last);
|
||||
await tester.tap(find.byKey(commentsBackKey));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Comments 2'), findsOneWidget);
|
||||
|
|
@ -647,8 +646,8 @@ void main() {
|
|||
expect(find.text('Ask the nurse', findRichText: true), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets("a comment's menu offers every emoji, nothing picked "
|
||||
'leaving it as it was', (tester) async {
|
||||
testWidgets("a comment's menu offers the emoji, one picked showing on "
|
||||
'it; leaving picks none', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await openTask(tester);
|
||||
await openComments(tester);
|
||||
|
|
@ -658,8 +657,67 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Add a reaction'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Search emoji'), findsOneWidget);
|
||||
expect(find.text('RECENTLY USED'), findsOneWidget);
|
||||
|
||||
expect(find.byType(EmojiPicker), findsOneWidget);
|
||||
await tester.tap(find.text('👍'));
|
||||
await tester.pumpAndSettle();
|
||||
commentRows.bringRemoteChanges();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('👍 1'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('the search finds emoji by their names', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await openTask(tester);
|
||||
await openComments(tester);
|
||||
await write(tester, 'Ask the vet');
|
||||
await tester.tap(find.byTooltip('Comment menu'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Add a reaction'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField).last, 'rocket');
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('🚀'), findsOneWidget);
|
||||
expect(find.text('RECENTLY USED'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the sheet opened by Show all keeps the keyboard closed, '
|
||||
'the bar writing at once', (tester) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await openTask(tester);
|
||||
await openComments(tester);
|
||||
await write(tester, 'First note');
|
||||
await tester.tap(find.byKey(commentsBackKey));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.text('Show all'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(commentSendKey), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('the heading folds what shows of the comments', (
|
||||
tester,
|
||||
) async {
|
||||
await start(tester, usedBefore: true);
|
||||
await openTask(tester);
|
||||
await openComments(tester);
|
||||
await write(tester, 'First note');
|
||||
await tester.tap(find.byKey(commentsBackKey));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Show all'), findsOneWidget);
|
||||
await tester.ensureVisible(find.byKey(previewHeadingKey));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(previewHeadingKey));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Show all'), findsNothing);
|
||||
expect(find.text('Comments 1'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('a reaction kept shows on its comment, and a tap takes it '
|
||||
|
|
@ -4047,6 +4105,8 @@ void main() {
|
|||
await tester.pumpAndSettle();
|
||||
expect(find.text('Priority 1'), findsOneWidget);
|
||||
|
||||
await tester.ensureVisible(find.text('Add sub-task'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Add sub-task'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byType(TextField).last, 'Socks');
|
||||
|
|
|
|||
Loading…
Reference in a new issue