fixed countdown wrap, css rgba colors, stale create-link gate and wired up reply drafts in talk

This commit is contained in:
2026-09-27 21:13:44 +02:00
parent 2664378b17
commit aca86c506a
5 changed files with 143 additions and 119 deletions
+79 -88
View File
@@ -50,10 +50,6 @@ class _ShareSheetBodyState extends State<_ShareSheetBody> {
late Future<List<Share>> _future = FileSharingApi().listForPath(_ocsPath);
bool _busy = false;
/// Last resolved share list — used by the create-link gate without depending
/// on the FutureBuilder's snapshot.
List<Share>? _lastShares;
void _reload() {
// Block body: an arrow would return the Future, which setState rejects.
setState(() {
@@ -126,103 +122,98 @@ class _ShareSheetBodyState extends State<_ShareSheetBody> {
@override
Widget build(BuildContext context) {
final caps = context.watch<NextcloudCapabilitiesCubit>();
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (_busy) const LinearProgressIndicator(),
_shareList(),
const Divider(height: 1),
if (caps.canShareWithUsers || caps.canShareWithGroups)
ListTile(
leading: const CenteredLeading(Icon(Icons.person_add_outlined)),
title: const Text('Person oder Gruppe hinzufügen'),
enabled: !_busy,
onTap: _addSharee,
),
if (caps.canCreatePublicLinks && _canAddLink(caps))
ListTile(
leading: const CenteredLeading(Icon(Icons.add_link)),
title: const Text('Öffentlichen Link erstellen'),
enabled: !_busy,
onTap: _createPublicLink,
),
],
return FutureBuilder<List<Share>>(
future: _future,
builder: (context, snapshot) => Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
if (_busy) const LinearProgressIndicator(),
_shareList(snapshot),
const Divider(height: 1),
if (caps.canShareWithUsers || caps.canShareWithGroups)
ListTile(
leading: const CenteredLeading(Icon(Icons.person_add_outlined)),
title: const Text('Person oder Gruppe hinzufügen'),
enabled: !_busy,
onTap: _addSharee,
),
if (caps.canCreatePublicLinks && _canAddLink(caps, snapshot.data))
ListTile(
leading: const CenteredLeading(Icon(Icons.add_link)),
title: const Text('Öffentlichen Link erstellen'),
enabled: !_busy,
onTap: _createPublicLink,
),
],
),
);
}
/// Whether another public link may be created — hidden once one exists on a
/// server that disallows multiple links.
bool _canAddLink(NextcloudCapabilitiesCubit caps) {
if (caps.allowsMultipleLinks) return true;
final hasLink = _lastShares?.any((s) => s.isPublicLink) ?? false;
return !hasLink;
}
bool _canAddLink(NextcloudCapabilitiesCubit caps, List<Share>? shares) =>
caps.allowsMultipleLinks ||
!(shares?.any((s) => s.isPublicLink) ?? false);
Widget _shareList() {
return FutureBuilder<List<Share>>(
future: _future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Padding(
padding: EdgeInsets.all(24),
child: Center(child: AppProgressIndicator.medium()),
);
}
if (snapshot.hasError) {
return Padding(
padding: const EdgeInsets.all(24),
Widget _shareList(AsyncSnapshot<List<Share>> snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Padding(
padding: EdgeInsets.all(24),
child: Center(child: AppProgressIndicator.medium()),
);
}
if (snapshot.hasError) {
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
children: [
Text(
errorToUserMessage(snapshot.error),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: _reload,
child: const Text('Erneut versuchen'),
),
],
),
);
}
final shares = snapshot.data ?? const <Share>[];
if (shares.isEmpty) {
final theme = Theme.of(context);
return SizedBox(
height: 160,
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
errorToUserMessage(snapshot.error),
textAlign: TextAlign.center,
Icon(
Icons.lock_outline,
size: 48,
color: theme.colorScheme.outline,
),
const SizedBox(height: 12),
FilledButton.tonal(
onPressed: _reload,
child: const Text('Erneut versuchen'),
Text(
'Diese Datei ist noch nicht freigegeben.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
);
}
final shares = snapshot.data ?? const <Share>[];
_lastShares = shares;
if (shares.isEmpty) {
final theme = Theme.of(context);
return SizedBox(
height: 160,
child: Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.lock_outline,
size: 48,
color: theme.colorScheme.outline,
),
const SizedBox(height: 12),
Text(
'Diese Datei ist noch nicht freigegeben.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: shares.map(_shareTile).toList(),
);
},
),
),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: shares.map(_shareTile).toList(),
);
}
@@ -44,6 +44,7 @@ class _ChatTextfieldState extends State<ChatTextfield> {
final AsyncActionController _sendController = AsyncActionController();
final FocusNode _focusNode = FocusNode();
final GlobalKey _sizeKey = GlobalKey();
late final StreamSubscription<void> _replySubscription;
String? _sendError;
/// Publishes the input bar's measured height so the downloads chip floats
@@ -114,13 +115,20 @@ class _ChatTextfieldState extends State<ChatTextfield> {
super.initState();
settings = context.read<SettingsCubit>();
_loadDraft();
final draftReply = settings
.val()
.talkSettings
.draftReplies[widget.sendToToken];
if (draftReply != null) {
context.read<ChatBloc>().setReferenceMessageId(draftReply);
_replySubscription = context.read<ChatBloc>().stream.listen(
(state) => _persistPickedReply(state.data),
);
}
/// Clearing is handled where it happens (send, dismiss); the bloc also
/// drops the reference on every room switch, which must not erase the draft.
void _persistPickedReply(ChatState? chatState) {
final id = chatState?.referenceMessageId;
if (id == null || chatState!.currentToken != widget.sendToToken) return;
if (settings.val().talkSettings.draftReplies[widget.sendToToken] == id) {
return;
}
_setDraftReply(id);
}
@override
@@ -137,8 +145,12 @@ class _ChatTextfieldState extends State<ChatTextfield> {
/// so doing this per build would drop the cursor position, any active text
/// selection and a running IME composition on every chat update.
void _loadDraft() {
_textBoxController.text =
settings.val().talkSettings.drafts[widget.sendToToken] ?? '';
final talkSettings = settings.val().talkSettings;
_textBoxController.text = talkSettings.drafts[widget.sendToToken] ?? '';
final draftReply = talkSettings.draftReplies[widget.sendToToken];
if (draftReply != null) {
context.read<ChatBloc>().setReferenceMessageId(draftReply);
}
}
@override
@@ -148,6 +160,7 @@ class _ChatTextfieldState extends State<ChatTextfield> {
WidgetsBinding.instance.addPostFrameCallback(
(_) => downloadChipBottomObstruction.value = 0,
);
unawaited(_replySubscription.cancel());
_sendController.dispose();
_focusNode.dispose();
super.dispose();
+3 -15
View File
@@ -33,23 +33,11 @@ class _AnimatedTimeState extends State<AnimatedTime> {
const Text('Noch '),
buildWidget(current.inDays),
const Text(' Tage, '),
buildWidget(
current.inHours > 24
? current.inHours - current.inDays * 24
: current.inHours,
),
buildWidget(current.inHours.remainder(24)),
const Text(':'),
buildWidget(
current.inMinutes > 60
? current.inMinutes - current.inHours * 60
: current.inMinutes,
),
buildWidget(current.inMinutes.remainder(60)),
const Text(':'),
buildWidget(
current.inSeconds > 60
? current.inSeconds - current.inMinutes * 60
: current.inSeconds,
),
buildWidget(current.inSeconds.remainder(60)),
],
);
+11 -8
View File
@@ -142,7 +142,7 @@ class _PmRichTextState extends State<PmRichText> {
}
Color _highlightColor(dynamic color, ThemeData theme) {
final parsed = color is String ? _parseCssColor(color) : null;
final parsed = color is String ? parseCssColor(color) : null;
final base = parsed ?? theme.colorScheme.tertiaryContainer;
// Full-opacity highlights swamp the text in dark mode, so dial the alpha
// down further there while keeping the accent readable in light mode.
@@ -178,19 +178,22 @@ class _PmRichTextState extends State<PmRichText> {
/// Parses a CSS `#rgb`/`#rrggbb`/`#rrggbbaa` hex or a small set of named colors.
/// Returns `null` for anything unrecognised so the caller can fall back.
Color? _parseCssColor(String raw) {
@visibleForTesting
Color? parseCssColor(String raw) {
final value = raw.trim().toLowerCase();
if (value.startsWith('#')) {
var hex = value.substring(1);
if (hex.length == 3) {
hex = hex.split('').map((c) => '$c$c').join();
}
if (hex.length == 6) hex = 'ff$hex';
if (hex.length == 8) {
final rgba = int.tryParse(hex, radix: 16);
if (rgba != null) return Color(rgba);
}
return null;
// CSS puts alpha last, Flutter's Color expects it first.
final argb = switch (hex.length) {
6 => 'ff$hex',
8 => hex.substring(6) + hex.substring(0, 6),
_ => null,
};
final parsed = argb == null ? null : int.tryParse(argb, radix: 16);
return parsed == null ? null : Color(parsed);
}
return _namedColors[value];
}