Skip to content

Commit

Permalink
Feature/links (#102)
Browse files Browse the repository at this point in the history
* feat(link): add highlighting links and open them.

* fix(admin): handle fetching users when being an admin.
  • Loading branch information
marwan2232004 authored Dec 20, 2024
1 parent 2f9d470 commit 6beeaaf
Show file tree
Hide file tree
Showing 3 changed files with 251 additions and 16 deletions.
142 changes: 128 additions & 14 deletions lib/core/view/widget/highlight_text_widget.dart
Original file line number Diff line number Diff line change
@@ -1,50 +1,164 @@
import 'package:collection/collection.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:telware_cross_platform/core/theme/palette.dart';
import 'package:telware_cross_platform/core/utils.dart';
import 'package:url_launcher/url_launcher.dart';

bool isHighlight(
int index,
int lowerBoundIndex,
List<MapEntry<int, int>> list,
) {
if (lowerBoundIndex < list.length) {
int highlightStart = list[lowerBoundIndex].key;
int highlightEnd = highlightStart + list[lowerBoundIndex].value;
return index >= highlightStart && index < highlightEnd;
}
return false;
}

class HighlightTextWidget extends StatelessWidget {
final String text;
final List<MapEntry<int, int>>
highlights; // Each entry holds start index and length of highlight
final TextStyle normalStyle;
final TextStyle highlightStyle;
final TextStyle linkStyle;
final TextOverflow overFlow;
final int? maxLines;

const HighlightTextWidget({
final RegExp _linkRegExp = RegExp(
r'((https?|ftp)://[^\s/$.?#].[^\s]*)',
caseSensitive: false,
);

HighlightTextWidget({
super.key,
required this.text,
required this.highlights,
this.normalStyle = const TextStyle(color: Palette.primaryText),
this.highlightStyle = const TextStyle(color: Palette.error),
this.linkStyle = const TextStyle(
color: Palette.accent,
decoration: TextDecoration.underline,
),
this.overFlow = TextOverflow.clip,
this.maxLines,
});

Future<void> _launchURL(String url) async {
final Uri uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
showToastMessage('Could not launch $url');
}
}

@override
Widget build(BuildContext context) {
if (highlights.length == 1 && highlights[0].key != 0) {
debugPrint(highlights.toString());
}
List<TextSpan> textSpans = [];
int start = 0;

for (var highlight in highlights) {
int index = highlight.key;
int length = highlight.value;
// Find all links in the text
final List<RegExpMatch> linkMatches = _linkRegExp.allMatches(text).toList();
final List<MapEntry<int, int>> linkRanges = linkMatches
.map((match) => MapEntry(match.start, match.end - match.start))
.toList();

final List<MapEntry<int, String>> highlightRanges =
List.generate(text.length, (index) => MapEntry(index, 'normal'));

if (index > start) {
textSpans.add(
TextSpan(text: text.substring(start, index), style: normalStyle));
for (var i = 0; i < highlightRanges.length; i++) {
int lowerBoundIndex = lowerBound<MapEntry<int, int>>(
highlights,
MapEntry(i, 0), // Create a temporary MapEntry with the target value
compare: (a, b) => a.key.compareTo(b.key), // Compare by the key
);
if (lowerBoundIndex != 0) {
lowerBoundIndex = (lowerBoundIndex < highlights.length &&
highlights[lowerBoundIndex].key == i)
? lowerBoundIndex
: lowerBoundIndex - 1;
}
if (isHighlight(i, lowerBoundIndex, highlights)) {
highlightRanges[i] = MapEntry(i, 'highlight');
continue;
}

String highlightedText = text.substring(index, index + length);
textSpans.add(TextSpan(text: highlightedText, style: highlightStyle));
lowerBoundIndex = lowerBound<MapEntry<int, int>>(
linkRanges,
MapEntry(i, 0), // Create a temporary MapEntry with the target value
compare: (a, b) => a.key.compareTo(b.key), // Compare by the key
);
if (lowerBoundIndex != 0) {
lowerBoundIndex = (lowerBoundIndex < linkRanges.length &&
linkRanges[lowerBoundIndex].key == i)
? lowerBoundIndex
: lowerBoundIndex - 1;
}
if (isHighlight(i, lowerBoundIndex, linkRanges)) {
highlightRanges[i] = MapEntry(i, 'link');
continue;
}
}

final List<MapEntry<String, MapEntry<int, int>>> groupedHighlights = [];

// group the same type of highlights together
if (highlightRanges.isNotEmpty) {
String currentType = highlightRanges[0].value;
int start = highlightRanges[0].key;
int length = 1;

for (int i = 1; i < highlightRanges.length; i++) {
if (highlightRanges[i].value == currentType) {
length++;
} else {
groupedHighlights.add(MapEntry(currentType, MapEntry(start, length)));

start = index + length;
currentType = highlightRanges[i].value;
start = highlightRanges[i].key;
length = 1;
}
}

groupedHighlights.add(MapEntry(currentType, MapEntry(start, length)));
}

if (start < text.length) {
textSpans.add(TextSpan(text: text.substring(start), style: normalStyle));
List<TextSpan> textSpans = [];

for (var highlight in groupedHighlights) {
int index = highlight.value.key;
int length = highlight.value.value;
String type = highlight.key;
switch (type) {
case 'normal':
textSpans.add(TextSpan(
text: text.substring(index, index + length),
style: normalStyle,
));
break;
case 'highlight':
textSpans.add(TextSpan(
text: text.substring(index, index + length),
style: highlightStyle,
));
break;
case 'link':
textSpans.add(
TextSpan(
text: text.substring(index, index + length),
style: linkStyle,
recognizer: TapGestureRecognizer()
..onTap =
() => _launchURL(text.substring(index, index + length)),
),
);
break;
}
}

return RichText(
Expand Down
118 changes: 118 additions & 0 deletions lib/core/view/widget/link_text_field.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';

class LinkTextField extends StatefulWidget {
final TextEditingController controller;
final bool notAllowedToSend;

const LinkTextField({
Key? key,
required this.controller,
this.notAllowedToSend = false,
}) : super(key: key);

@override
_LinkTextFieldState createState() => _LinkTextFieldState();
}

class _LinkTextFieldState extends State<LinkTextField> {
final FocusNode _textFieldFocusNode = FocusNode();
bool isTextEmpty = true;
bool _emojiShowing = false;
final RegExp _linkRegExp = RegExp(
r'((https?|ftp)://[^\s/$.?#].[^\s]*)',
caseSensitive: false,
);

Future<void> _launchURL(String url) async {
final Uri uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
throw 'Could not launch $url';
}
}

List<TextSpan> _buildTextSpans(String text) {
List<TextSpan> spans = [];
int start = 0;

_linkRegExp.allMatches(text).forEach((match) {
if (match.start > start) {
spans.add(TextSpan(text: text.substring(start, match.start)));
}
final String url = match.group(0)!;
spans.add(
TextSpan(
text: url,
style: const TextStyle(
color: Colors.blue, decoration: TextDecoration.underline),
recognizer: TapGestureRecognizer()..onTap = () => _launchURL(url),
),
);
start = match.end;
});

if (start < text.length) {
spans.add(TextSpan(text: text.substring(start)));
}

return spans;
}

@override
Widget build(BuildContext context) {
return Column(
children: [
TextField(
focusNode: _textFieldFocusNode,
controller: widget.controller,
enabled: !widget.notAllowedToSend,
decoration: InputDecoration(
hintText: widget.notAllowedToSend ? 'Text not Allowed' : 'Message',
hintStyle: const TextStyle(
fontSize: 18,
color: Colors.grey,
fontWeight: FontWeight.w400,
),
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
prefixIcon: widget.notAllowedToSend
? const Icon(
Icons.lock,
color: Colors.grey,
)
: null,
),
cursorColor: Colors.blue,
onTap: () {
if (widget.notAllowedToSend) return;
setState(() {
_emojiShowing = false;
});
},
onChanged: (text) {
if (isTextEmpty ^ text.isEmpty) {
setState(() {
isTextEmpty = text.isEmpty;
});
}
},
),
if (widget.controller.text.isNotEmpty)
Container(
padding: const EdgeInsets.all(8.0),
alignment: Alignment.topLeft,
child: RichText(
text: TextSpan(
style: const TextStyle(fontSize: 18, color: Colors.black),
children: _buildTextSpans(widget.controller.text),
),
),
),
],
);
}
}
7 changes: 5 additions & 2 deletions lib/features/chat/view/screens/create_chat_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,8 @@ class _CreateChatScreen extends ConsumerState<CreateChatScreen>
style: const TextStyle(color: Colors.red),
),
);
} else if (!snapshot.hasData || snapshot.data!.isEmpty) {
} else if ((!snapshot.hasData || snapshot.data!.isEmpty) &&
isAdmin) {
return const Center(
child: LottieViewer(
path: 'assets/json/utyan_empty.json',
Expand All @@ -252,7 +253,9 @@ class _CreateChatScreen extends ConsumerState<CreateChatScreen>
);
} else {
if (!_isUserContentSet) {
userChats = _generateUsersList(snapshot.data!, false);
userChats = isAdmin
? _generateUsersList(snapshot.data!, false)
: [];
_isUserContentSet = true;
}
return TabBarView(
Expand Down

0 comments on commit 6beeaaf

Please sign in to comment.