fixed countdown wrap, css rgba colors, stale create-link gate and wired up reply drafts in talk
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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)),
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:marianum_mobile/widget/prosemirror/pm_rich_text.dart';
|
||||
|
||||
void main() {
|
||||
group('parseCssColor', () {
|
||||
test('expands #rgb', () {
|
||||
expect(parseCssColor('#f00'), const Color(0xFFFF0000));
|
||||
});
|
||||
|
||||
test('parses #rrggbb as opaque', () {
|
||||
expect(parseCssColor(' #3B82F6 '), const Color(0xFF3B82F6));
|
||||
});
|
||||
|
||||
test('reads #rrggbbaa with alpha last', () {
|
||||
expect(parseCssColor('#3b82f680'), const Color(0x803B82F6));
|
||||
});
|
||||
|
||||
test('resolves named colors', () {
|
||||
expect(parseCssColor('Red'), const Color(0xFFEF4444));
|
||||
});
|
||||
|
||||
test('rejects malformed input', () {
|
||||
expect(parseCssColor('#12345'), isNull);
|
||||
expect(parseCssColor('#zzzzzz'), isNull);
|
||||
expect(parseCssColor('rgb(0,0,0)'), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user