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);
|
late Future<List<Share>> _future = FileSharingApi().listForPath(_ocsPath);
|
||||||
bool _busy = false;
|
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() {
|
void _reload() {
|
||||||
// Block body: an arrow would return the Future, which setState rejects.
|
// Block body: an arrow would return the Future, which setState rejects.
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -126,103 +122,98 @@ class _ShareSheetBodyState extends State<_ShareSheetBody> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final caps = context.watch<NextcloudCapabilitiesCubit>();
|
final caps = context.watch<NextcloudCapabilitiesCubit>();
|
||||||
return Column(
|
return FutureBuilder<List<Share>>(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
future: _future,
|
||||||
mainAxisSize: MainAxisSize.min,
|
builder: (context, snapshot) => Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
if (_busy) const LinearProgressIndicator(),
|
mainAxisSize: MainAxisSize.min,
|
||||||
_shareList(),
|
children: [
|
||||||
const Divider(height: 1),
|
if (_busy) const LinearProgressIndicator(),
|
||||||
if (caps.canShareWithUsers || caps.canShareWithGroups)
|
_shareList(snapshot),
|
||||||
ListTile(
|
const Divider(height: 1),
|
||||||
leading: const CenteredLeading(Icon(Icons.person_add_outlined)),
|
if (caps.canShareWithUsers || caps.canShareWithGroups)
|
||||||
title: const Text('Person oder Gruppe hinzufügen'),
|
ListTile(
|
||||||
enabled: !_busy,
|
leading: const CenteredLeading(Icon(Icons.person_add_outlined)),
|
||||||
onTap: _addSharee,
|
title: const Text('Person oder Gruppe hinzufügen'),
|
||||||
),
|
enabled: !_busy,
|
||||||
if (caps.canCreatePublicLinks && _canAddLink(caps))
|
onTap: _addSharee,
|
||||||
ListTile(
|
),
|
||||||
leading: const CenteredLeading(Icon(Icons.add_link)),
|
if (caps.canCreatePublicLinks && _canAddLink(caps, snapshot.data))
|
||||||
title: const Text('Öffentlichen Link erstellen'),
|
ListTile(
|
||||||
enabled: !_busy,
|
leading: const CenteredLeading(Icon(Icons.add_link)),
|
||||||
onTap: _createPublicLink,
|
title: const Text('Öffentlichen Link erstellen'),
|
||||||
),
|
enabled: !_busy,
|
||||||
],
|
onTap: _createPublicLink,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether another public link may be created — hidden once one exists on a
|
/// Whether another public link may be created — hidden once one exists on a
|
||||||
/// server that disallows multiple links.
|
/// server that disallows multiple links.
|
||||||
bool _canAddLink(NextcloudCapabilitiesCubit caps) {
|
bool _canAddLink(NextcloudCapabilitiesCubit caps, List<Share>? shares) =>
|
||||||
if (caps.allowsMultipleLinks) return true;
|
caps.allowsMultipleLinks ||
|
||||||
final hasLink = _lastShares?.any((s) => s.isPublicLink) ?? false;
|
!(shares?.any((s) => s.isPublicLink) ?? false);
|
||||||
return !hasLink;
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _shareList() {
|
Widget _shareList(AsyncSnapshot<List<Share>> snapshot) {
|
||||||
return FutureBuilder<List<Share>>(
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
future: _future,
|
return const Padding(
|
||||||
builder: (context, snapshot) {
|
padding: EdgeInsets.all(24),
|
||||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
child: Center(child: AppProgressIndicator.medium()),
|
||||||
return const Padding(
|
);
|
||||||
padding: EdgeInsets.all(24),
|
}
|
||||||
child: Center(child: AppProgressIndicator.medium()),
|
if (snapshot.hasError) {
|
||||||
);
|
return Padding(
|
||||||
}
|
padding: const EdgeInsets.all(24),
|
||||||
if (snapshot.hasError) {
|
child: Column(
|
||||||
return Padding(
|
children: [
|
||||||
padding: const EdgeInsets.all(24),
|
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(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Icon(
|
||||||
errorToUserMessage(snapshot.error),
|
Icons.lock_outline,
|
||||||
textAlign: TextAlign.center,
|
size: 48,
|
||||||
|
color: theme.colorScheme.outline,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
FilledButton.tonal(
|
Text(
|
||||||
onPressed: _reload,
|
'Diese Datei ist noch nicht freigegeben.',
|
||||||
child: const Text('Erneut versuchen'),
|
textAlign: TextAlign.center,
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}
|
),
|
||||||
final shares = snapshot.data ?? const <Share>[];
|
);
|
||||||
_lastShares = shares;
|
}
|
||||||
if (shares.isEmpty) {
|
return Column(
|
||||||
final theme = Theme.of(context);
|
mainAxisSize: MainAxisSize.min,
|
||||||
return SizedBox(
|
children: shares.map(_shareTile).toList(),
|
||||||
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(),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
|||||||
final AsyncActionController _sendController = AsyncActionController();
|
final AsyncActionController _sendController = AsyncActionController();
|
||||||
final FocusNode _focusNode = FocusNode();
|
final FocusNode _focusNode = FocusNode();
|
||||||
final GlobalKey _sizeKey = GlobalKey();
|
final GlobalKey _sizeKey = GlobalKey();
|
||||||
|
late final StreamSubscription<void> _replySubscription;
|
||||||
String? _sendError;
|
String? _sendError;
|
||||||
|
|
||||||
/// Publishes the input bar's measured height so the downloads chip floats
|
/// Publishes the input bar's measured height so the downloads chip floats
|
||||||
@@ -114,13 +115,20 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
settings = context.read<SettingsCubit>();
|
settings = context.read<SettingsCubit>();
|
||||||
_loadDraft();
|
_loadDraft();
|
||||||
final draftReply = settings
|
_replySubscription = context.read<ChatBloc>().stream.listen(
|
||||||
.val()
|
(state) => _persistPickedReply(state.data),
|
||||||
.talkSettings
|
);
|
||||||
.draftReplies[widget.sendToToken];
|
}
|
||||||
if (draftReply != null) {
|
|
||||||
context.read<ChatBloc>().setReferenceMessageId(draftReply);
|
/// 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
|
@override
|
||||||
@@ -137,8 +145,12 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
|||||||
/// so doing this per build would drop the cursor position, any active text
|
/// so doing this per build would drop the cursor position, any active text
|
||||||
/// selection and a running IME composition on every chat update.
|
/// selection and a running IME composition on every chat update.
|
||||||
void _loadDraft() {
|
void _loadDraft() {
|
||||||
_textBoxController.text =
|
final talkSettings = settings.val().talkSettings;
|
||||||
settings.val().talkSettings.drafts[widget.sendToToken] ?? '';
|
_textBoxController.text = talkSettings.drafts[widget.sendToToken] ?? '';
|
||||||
|
final draftReply = talkSettings.draftReplies[widget.sendToToken];
|
||||||
|
if (draftReply != null) {
|
||||||
|
context.read<ChatBloc>().setReferenceMessageId(draftReply);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -148,6 +160,7 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
|||||||
WidgetsBinding.instance.addPostFrameCallback(
|
WidgetsBinding.instance.addPostFrameCallback(
|
||||||
(_) => downloadChipBottomObstruction.value = 0,
|
(_) => downloadChipBottomObstruction.value = 0,
|
||||||
);
|
);
|
||||||
|
unawaited(_replySubscription.cancel());
|
||||||
_sendController.dispose();
|
_sendController.dispose();
|
||||||
_focusNode.dispose();
|
_focusNode.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
|
|||||||
@@ -33,23 +33,11 @@ class _AnimatedTimeState extends State<AnimatedTime> {
|
|||||||
const Text('Noch '),
|
const Text('Noch '),
|
||||||
buildWidget(current.inDays),
|
buildWidget(current.inDays),
|
||||||
const Text(' Tage, '),
|
const Text(' Tage, '),
|
||||||
buildWidget(
|
buildWidget(current.inHours.remainder(24)),
|
||||||
current.inHours > 24
|
|
||||||
? current.inHours - current.inDays * 24
|
|
||||||
: current.inHours,
|
|
||||||
),
|
|
||||||
const Text(':'),
|
const Text(':'),
|
||||||
buildWidget(
|
buildWidget(current.inMinutes.remainder(60)),
|
||||||
current.inMinutes > 60
|
|
||||||
? current.inMinutes - current.inHours * 60
|
|
||||||
: current.inMinutes,
|
|
||||||
),
|
|
||||||
const Text(':'),
|
const Text(':'),
|
||||||
buildWidget(
|
buildWidget(current.inSeconds.remainder(60)),
|
||||||
current.inSeconds > 60
|
|
||||||
? current.inSeconds - current.inMinutes * 60
|
|
||||||
: current.inSeconds,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ class _PmRichTextState extends State<PmRichText> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Color _highlightColor(dynamic color, ThemeData theme) {
|
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;
|
final base = parsed ?? theme.colorScheme.tertiaryContainer;
|
||||||
// Full-opacity highlights swamp the text in dark mode, so dial the alpha
|
// Full-opacity highlights swamp the text in dark mode, so dial the alpha
|
||||||
// down further there while keeping the accent readable in light mode.
|
// 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.
|
/// 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.
|
/// 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();
|
final value = raw.trim().toLowerCase();
|
||||||
if (value.startsWith('#')) {
|
if (value.startsWith('#')) {
|
||||||
var hex = value.substring(1);
|
var hex = value.substring(1);
|
||||||
if (hex.length == 3) {
|
if (hex.length == 3) {
|
||||||
hex = hex.split('').map((c) => '$c$c').join();
|
hex = hex.split('').map((c) => '$c$c').join();
|
||||||
}
|
}
|
||||||
if (hex.length == 6) hex = 'ff$hex';
|
// CSS puts alpha last, Flutter's Color expects it first.
|
||||||
if (hex.length == 8) {
|
final argb = switch (hex.length) {
|
||||||
final rgba = int.tryParse(hex, radix: 16);
|
6 => 'ff$hex',
|
||||||
if (rgba != null) return Color(rgba);
|
8 => hex.substring(6) + hex.substring(0, 6),
|
||||||
}
|
_ => null,
|
||||||
return null;
|
};
|
||||||
|
final parsed = argb == null ? null : int.tryParse(argb, radix: 16);
|
||||||
|
return parsed == null ? null : Color(parsed);
|
||||||
}
|
}
|
||||||
return _namedColors[value];
|
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