Compare commits

...

5 Commits

Author SHA1 Message Date
9159fe0fe1 add font customization. (#159) 2023-02-22 15:54:01 -08:00
7c51bad35e add shortcuts for wikipedia and wiktionary. (#157) 2023-02-22 13:37:32 -08:00
6836138d11 fix quote rendering. (#158) 2023-02-22 11:30:33 -08:00
2f71964277 linkifier cleanup. (#156) 2023-02-22 00:15:52 -08:00
c24c5c1b7a add formatting support (#155) 2023-02-21 23:40:25 -08:00
30 changed files with 822 additions and 84 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -0,0 +1,5 @@
- Customization of tab bar.
- Option to enable swipe gesture for switching between tabs.
- Access to action menu from home screen.
- Access to Wikipedia and Wiktionary from text selection toolbar.
- Quotes and emphasis rendering.

View File

@ -16,6 +16,8 @@ abstract class Constants {
'https://news.ycombinator.com/newsguidelines.html';
static const String githubIssueLink =
'$githubLink/issues/new?title=Found+a+bug+in+Hacki&body=Please+describe+the+problem.';
static const String wikipediaLink = 'https://en.wikipedia.org/wiki/';
static const String wiktionaryLink = 'https://en.wiktionary.org/wiki/';
static const String supportEmail = 'georgefung98@gmail.com';
static const String _imagePath = 'assets/images';

View File

@ -3,6 +3,7 @@ import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:hacki/config/locator.dart';
@ -11,7 +12,9 @@ import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/utils/linkifier_util.dart';
import 'package:logger/logger.dart';
import 'package:rxdart/rxdart.dart';
part 'comments_state.dart';
@ -89,6 +92,8 @@ class CommentsCubit extends Cubit<CommentsState> {
ids: targetParents!.last.kids,
level: targetParents.last.level + 1,
)
.asyncMap(_toBuildableComment)
.whereNotNull()
.listen(_onCommentFetched)
..onDone(_onDone);
@ -111,33 +116,32 @@ class CommentsCubit extends Cubit<CommentsState> {
emit(state.copyWith(item: updatedItem));
late final Stream<Comment> commentStream;
if (state.offlineReading) {
_streamSubscription = _offlineRepository
.getCachedCommentsStream(ids: kids)
.listen(_onCommentFetched)
..onDone(_onDone);
commentStream = _offlineRepository.getCachedCommentsStream(ids: kids);
} else {
switch (state.fetchMode) {
case FetchMode.lazy:
_streamSubscription = _storiesRepository
.fetchCommentsStream(
commentStream = _storiesRepository.fetchCommentsStream(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
)
.listen(_onCommentFetched)
..onDone(_onDone);
);
break;
case FetchMode.eager:
_streamSubscription = _storiesRepository
.fetchAllCommentsRecursivelyStream(
commentStream = _storiesRepository.fetchAllCommentsRecursivelyStream(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
)
.listen(_onCommentFetched)
..onDone(_onDone);
);
break;
}
}
_streamSubscription = commentStream
.asyncMap(_toBuildableComment)
.whereNotNull()
.listen(_onCommentFetched)
..onDone(_onDone);
}
Future<void> refresh() async {
@ -176,21 +180,22 @@ class CommentsCubit extends Cubit<CommentsState> {
await _storiesRepository.fetchItem(id: item.id) ?? item;
final List<int> kids = sortKids(updatedItem.kids);
late final Stream<Comment> commentStream;
if (state.fetchMode == FetchMode.lazy) {
_streamSubscription = _storiesRepository
.fetchCommentsStream(
commentStream = _storiesRepository.fetchCommentsStream(
ids: kids,
)
.listen(_onCommentFetched)
..onDone(_onDone);
);
} else {
_streamSubscription = _storiesRepository
.fetchAllCommentsRecursivelyStream(
commentStream = _storiesRepository.fetchAllCommentsRecursivelyStream(
ids: kids,
)
);
}
_streamSubscription = commentStream
.asyncMap(_toBuildableComment)
.whereNotNull()
.listen(_onCommentFetched)
..onDone(_onDone);
}
emit(
state.copyWith(
@ -227,23 +232,18 @@ class CommentsCubit extends Cubit<CommentsState> {
final StreamSubscription<Comment> streamSubscription =
_storiesRepository
.fetchCommentsStream(ids: comment.kids)
.asyncMap(_toBuildableComment)
.whereNotNull()
.listen((Comment cmt) {
_collapseCache.addKid(cmt.id, to: cmt.parent);
_commentCache.cacheComment(cmt);
_sembastRepository.cacheComment(cmt);
final List<LinkifyElement> elements = _linkify(
cmt.text,
);
final BuildableComment buildableComment =
BuildableComment.fromComment(cmt, elements: elements);
emit(
state.copyWith(
comments: <Comment>[...state.comments]..insert(
state.comments.indexOf(comment) + offset + 1,
buildableComment.copyWith(level: level),
comment.copyWith(level: level),
),
),
);
@ -340,22 +340,15 @@ class CommentsCubit extends Cubit<CommentsState> {
);
}
void _onCommentFetched(Comment? comment) {
void _onCommentFetched(BuildableComment? comment) {
if (comment != null) {
_collapseCache.addKid(comment.id, to: comment.parent);
_commentCache.cacheComment(comment);
_sembastRepository.cacheComment(comment);
final List<LinkifyElement> elements = _linkify(
comment.text,
);
final BuildableComment buildableComment =
BuildableComment.fromComment(comment, elements: elements);
final List<Comment> updatedComments = <Comment>[
...state.comments,
buildableComment
comment
];
emit(state.copyWith(comments: updatedComments));
@ -387,29 +380,19 @@ class CommentsCubit extends Cubit<CommentsState> {
}
}
static List<LinkifyElement> _linkify(
String text, {
LinkifyOptions options = const LinkifyOptions(),
List<Linkifier> linkifiers = const <Linkifier>[
UrlLinkifier(),
EmailLinkifier(),
],
}) {
List<LinkifyElement> list = <LinkifyElement>[TextElement(text)];
static Future<BuildableComment?> _toBuildableComment(Comment? comment) async {
if (comment == null) return null;
if (text.isEmpty) {
return <LinkifyElement>[];
}
final List<LinkifyElement> elements =
await compute<String, List<LinkifyElement>>(
LinkifierUtil.linkify,
comment.text,
);
if (linkifiers.isEmpty) {
return list;
}
final BuildableComment buildableComment =
BuildableComment.fromComment(comment, elements: elements);
for (final Linkifier linkifier in linkifiers) {
list = linkifier.parse(list, options);
}
return list;
return buildableComment;
}
@override

View File

@ -96,6 +96,9 @@ class PreferenceState extends Equatable {
FontSize get fontSize => FontSize.values
.elementAt(preferences.singleWhereType<FontSizePreference>().val);
Font get font =>
Font.values.elementAt(preferences.singleWhereType<FontPreference>().val);
@override
List<Object?> get props => <Object?>[
...preferences.map<dynamic>((Preference<dynamic> e) => e.val),

View File

@ -1,4 +1,8 @@
import 'package:flutter/material.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/custom_linkify/linkifiers/linkifiers.dart';
import 'package:hacki/utils/utils.dart';
extension WidgetModifier on Widget {
Widget padded([EdgeInsetsGeometry value = const EdgeInsets.all(12)]) {
@ -7,4 +11,59 @@ extension WidgetModifier on Widget {
child: this,
);
}
Widget contextMenuBuilder(
BuildContext context,
EditableTextState editableTextState, {
required BuildableComment comment,
}) {
final Iterable<EmphasisElement> emphasisElements =
comment.elements.whereType<EmphasisElement>();
final int start = editableTextState.textEditingValue.selection.base.offset;
final int end = editableTextState.textEditingValue.selection.end;
final List<ContextMenuButtonItem> items = <ContextMenuButtonItem>[
...editableTextState.contextMenuButtonItems,
];
if (start != -1 && end != -1) {
String selectedText = comment.text.substring(start, end);
int count = 1;
while (selectedText.contains(' ') && count <= emphasisElements.length) {
final int s = (start + count * 2).clamp(0, comment.text.length);
final int e = (end + count * 2).clamp(0, comment.text.length);
selectedText = comment.text.substring(s, e);
count++;
}
count = 1;
while (selectedText.contains(' ') && count <= emphasisElements.length) {
final int s = (start - count * 2).clamp(0, comment.text.length);
final int e = (end - count * 2).clamp(0, comment.text.length);
selectedText = comment.text.substring(s, e);
count++;
}
items.addAll(<ContextMenuButtonItem>[
ContextMenuButtonItem(
onPressed: () => LinkUtil.launch(
'''${Constants.wikipediaLink}$selectedText''',
),
label: 'Wikipedia',
),
ContextMenuButtonItem(
onPressed: () => LinkUtil.launch(
'''${Constants.wiktionaryLink}$selectedText''',
),
label: 'Wiktionary',
),
]);
}
return AdaptiveTextSelectionToolbar.buttonItems(
anchors: editableTextState.contextMenuAnchors,
buttonItems: items,
);
}
}

View File

@ -128,6 +128,9 @@ Future<void> main({bool testing = false}) async {
final SharedPreferences prefs = await SharedPreferences.getInstance();
final bool trueDarkMode =
prefs.getBool(const TrueDarkModePreference().key) ?? false;
final Font font = Font.values.elementAt(
prefs.getInt(FontPreference().key) ?? Font.roboto.index,
);
Bloc.observer = CustomBlocObserver();
@ -137,6 +140,7 @@ Future<void> main({bool testing = false}) async {
HackiApp(
savedThemeMode: savedThemeMode,
trueDarkMode: trueDarkMode,
font: font,
),
);
}
@ -146,9 +150,11 @@ class HackiApp extends StatelessWidget {
super.key,
this.savedThemeMode,
required this.trueDarkMode,
required this.font,
});
final AdaptiveThemeMode? savedThemeMode;
final Font font;
final bool trueDarkMode;
static final GlobalKey<NavigatorState> navigatorKey =
@ -227,11 +233,13 @@ class HackiApp extends StatelessWidget {
child: AdaptiveTheme(
light: ThemeData(
primarySwatch: Palette.orange,
fontFamily: font.name,
),
dark: ThemeData(
brightness: Brightness.dark,
primarySwatch: Palette.orange,
canvasColor: trueDarkMode ? Palette.black : null,
fontFamily: font.name,
),
initial: savedThemeMode ?? AdaptiveThemeMode.system,
builder: (ThemeData theme, ThemeData darkTheme) {
@ -239,6 +247,7 @@ class HackiApp extends StatelessWidget {
brightness: Brightness.dark,
primarySwatch: Palette.orange,
canvasColor: Palette.black,
fontFamily: font.name,
);
return FutureBuilder<AdaptiveThemeMode?>(
future: AdaptiveTheme.getThemeMode(),

10
lib/models/font.dart Normal file
View File

@ -0,0 +1,10 @@
enum Font {
roboto('Roboto'),
robotoSlab('Roboto Slab'),
ubuntu('Ubuntu'),
ubuntuMono('Ubuntu Mono');
const Font(this.label);
final String label;
}

View File

@ -2,6 +2,7 @@ export 'buildable_comment.dart';
export 'comment.dart';
export 'comments_order.dart';
export 'fetch_mode.dart';
export 'font.dart';
export 'font_size.dart';
export 'item.dart';
export 'poll_option.dart';

View File

@ -20,6 +20,7 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
// Order of these first four preferences does not matter.
FetchModePreference(),
CommentsOrderPreference(),
FontPreference(),
FontSizePreference(),
TabOrderPreference(),
// Order of items below matters and
@ -65,6 +66,7 @@ const bool _collapseModeDefaultValue = true;
final int _fetchModeDefaultValue = FetchMode.eager.index;
final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
final int _fontSizeDefaultValue = FontSize.regular.index;
final int _fontDefaultValue = Font.roboto.index;
final int _tabOrderDefaultValue =
StoryType.convertToSettingsValue(StoryType.values);
@ -325,6 +327,21 @@ class CommentsOrderPreference extends IntPreference {
String get title => 'Default comments order';
}
class FontPreference extends IntPreference {
FontPreference({int? val}) : super(val: val ?? _fontDefaultValue);
@override
FontPreference copyWith({required int? val}) {
return FontPreference(val: val);
}
@override
String get key => 'font';
@override
String get title => 'Default font';
}
class FontSizePreference extends IntPreference {
FontSizePreference({int? val}) : super(val: val ?? _fontSizeDefaultValue);

View File

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
@ -340,12 +339,8 @@ class _ParentItemSection extends StatelessWidget {
bottom: Dimens.pt12,
top: Dimens.pt12,
),
child: RichText(
textAlign: TextAlign.center,
textScaleFactor: MediaQuery.of(
context,
).textScaleFactor,
text: TextSpan(
child: Text.rich(
TextSpan(
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: prefState.fontSize.fontSize,
@ -378,6 +373,10 @@ class _ParentItemSection extends StatelessWidget {
),
],
),
textAlign: TextAlign.center,
textScaleFactor: MediaQuery.of(
context,
).textScaleFactor,
),
),
)

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';

View File

@ -219,6 +219,12 @@ class _SettingsState extends State<Settings> {
},
activeColor: Palette.orange,
),
ListTile(
title: const Text(
'Font',
),
onTap: showFontSettingDialog,
),
ListTile(
title: const Text(
'Theme',
@ -285,6 +291,56 @@ class _SettingsState extends State<Settings> {
);
}
void showFontSettingDialog() {
showDialog<void>(
context: context,
builder: (_) {
return BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (PreferenceState previous, PreferenceState current) =>
previous.font != current.font,
builder: (BuildContext context, PreferenceState state) {
return AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
for (final Font font in Font.values)
RadioListTile<Font>(
value: font,
groupValue: state.font,
onChanged: (Font? val) {
if (val != null) {
context.read<PreferenceCubit>().update(
FontPreference(),
to: val.index,
);
}
},
title: Text(
font.label,
style: TextStyle(fontFamily: font.name),
),
),
Row(
children: const <Widget>[
Text(
'*Restart required',
style: TextStyle(
fontSize: TextDimens.pt12,
color: Palette.grey,
),
),
Spacer(),
],
)
],
),
);
},
);
},
);
}
void showThemeSettingDialog() {
showDialog<void>(
context: context,

View File

@ -1,14 +1,12 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/bloc_builder_3.dart';
import 'package:hacki/screens/widgets/centered_text.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
@ -366,6 +364,15 @@ class _CommentText extends StatelessWidget {
onOpen: onLinkTapped,
),
onTap: () => onTextTapped(context),
contextMenuBuilder: (
BuildContext context,
EditableTextState editableTextState,
) =>
contextMenuBuilder(
context,
editableTextState,
comment: comment as BuildableComment,
),
);
} else {
return SelectableLinkify(

View File

@ -0,0 +1,390 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:hacki/screens/widgets/custom_linkify/linkifiers/linkifiers.dart';
import 'package:hacki/styles/palette.dart';
import 'package:hacki/utils/utils.dart';
import 'package:linkify/linkify.dart';
export 'package:linkify/linkify.dart'
show
LinkifyElement,
LinkifyOptions,
LinkableElement,
TextElement,
Linkifier,
UrlElement,
UrlLinkifier,
EmailElement,
EmailLinkifier;
/// Callback clicked link
typedef LinkCallback = void Function(LinkableElement link);
/// Turns URLs into links
class Linkify extends StatelessWidget {
const Linkify({
super.key,
required this.text,
this.linkifiers = defaultLinkifiers,
this.onOpen,
this.options = const LinkifyOptions(),
// TextSpan
this.style,
this.linkStyle,
// RichText
this.textAlign = TextAlign.start,
this.textDirection,
this.maxLines,
this.overflow = TextOverflow.clip,
this.textScaleFactor = 1.0,
this.softWrap = true,
this.strutStyle,
this.locale,
this.textWidthBasis = TextWidthBasis.parent,
this.textHeightBehavior,
});
/// Text to be linkified
final String text;
/// Linkifiers to be used for linkify
final List<Linkifier> linkifiers;
/// Callback for tapping a link
final LinkCallback? onOpen;
/// linkify's options.
final LinkifyOptions options;
// TextSpan
/// Style for non-link text
final TextStyle? style;
/// Style of link text
final TextStyle? linkStyle;
// Text.rich
/// How the text should be aligned horizontally.
final TextAlign textAlign;
/// Text direction of the text
final TextDirection? textDirection;
/// The maximum number of lines for the text to span, wrapping if necessary
final int? maxLines;
/// How visual overflow should be handled.
final TextOverflow overflow;
/// The number of font pixels for each logical pixel
final double textScaleFactor;
/// Whether the text should break at soft line breaks.
final bool softWrap;
/// The strut style used for the vertical layout
final StrutStyle? strutStyle;
/// Used to select a font when the same Unicode character can
/// be rendered differently, depending on the locale
final Locale? locale;
/// Defines how to measure the width of the rendered text.
final TextWidthBasis textWidthBasis;
/// Defines how the paragraph will apply TextStyle.height to the ascent of
/// the first line and descent of the last line.
final TextHeightBehavior? textHeightBehavior;
@override
Widget build(BuildContext context) {
final List<LinkifyElement> elements = linkify(
text,
options: options,
linkifiers: linkifiers,
);
return Text.rich(
buildTextSpan(
elements,
style: Theme.of(context).textTheme.bodyMedium?.merge(style),
onOpen: onOpen,
useMouseRegion: true,
linkStyle: Theme.of(context)
.textTheme
.bodyMedium
?.merge(style)
.copyWith(
color: Colors.blueAccent,
decoration: TextDecoration.underline,
)
.merge(linkStyle),
),
textAlign: textAlign,
textDirection: textDirection,
maxLines: maxLines,
overflow: overflow,
textScaleFactor: textScaleFactor,
softWrap: softWrap,
strutStyle: strutStyle,
locale: locale,
textWidthBasis: textWidthBasis,
textHeightBehavior: textHeightBehavior,
);
}
}
const UrlLinkifier _urlLinkifier = UrlLinkifier();
const EmailLinkifier _emailLinkifier = EmailLinkifier();
const QuoteLinkifier _quoteLinkifier = QuoteLinkifier();
const EmphasisLinkifier _emphasisLinkifier = EmphasisLinkifier();
const List<Linkifier> defaultLinkifiers = <Linkifier>[
_urlLinkifier,
_emailLinkifier,
_quoteLinkifier,
_emphasisLinkifier,
];
/// Turns URLs into links
class SelectableLinkify extends StatelessWidget {
const SelectableLinkify({
super.key,
required this.text,
this.linkifiers = defaultLinkifiers,
this.onOpen,
this.options = const LinkifyOptions(),
// TextSpan
this.style,
this.linkStyle,
// RichText
this.textAlign,
this.textDirection,
this.minLines,
this.maxLines,
// SelectableText
this.focusNode,
this.textScaleFactor = 1.0,
this.strutStyle,
this.showCursor = false,
this.autofocus = false,
this.cursorWidth = 2.0,
this.cursorRadius,
this.cursorColor,
this.dragStartBehavior = DragStartBehavior.start,
this.enableInteractiveSelection = true,
this.onTap,
this.scrollPhysics,
this.textWidthBasis,
this.textHeightBehavior,
this.cursorHeight,
this.selectionControls,
this.onSelectionChanged,
});
/// Text to be linkified
final String text;
/// The number of font pixels for each logical pixel
final double textScaleFactor;
/// Linkifiers to be used for linkify
final List<Linkifier> linkifiers;
/// Callback for tapping a link
final LinkCallback? onOpen;
/// linkify's options.
final LinkifyOptions options;
// TextSpan
/// Style for non-link text
final TextStyle? style;
/// Style of link text
final TextStyle? linkStyle;
// Text.rich
/// How the text should be aligned horizontally.
final TextAlign? textAlign;
/// Text direction of the text
final TextDirection? textDirection;
/// The minimum number of lines to occupy when the content spans fewer lines.
final int? minLines;
/// The maximum number of lines for the text to span, wrapping if necessary
final int? maxLines;
/// The strut style used for the vertical layout
final StrutStyle? strutStyle;
/// Defines how to measure the width of the rendered text.
final TextWidthBasis? textWidthBasis;
// SelectableText.rich
/// Defines the focus for this widget.
final FocusNode? focusNode;
/// Whether to show cursor
final bool showCursor;
/// Whether this text field should focus itself if
/// nothing else is already focused.
final bool autofocus;
/// How thick the cursor will be
final double cursorWidth;
/// How rounded the corners of the cursor should be
final Radius? cursorRadius;
/// The color to use when painting the cursor
final Color? cursorColor;
/// Determines the way that drag start behavior is handled
final DragStartBehavior dragStartBehavior;
/// If true, then long-pressing this TextField will select text and show the cut/copy/paste menu,
/// and tapping will move the text caret
final bool enableInteractiveSelection;
/// Called when the user taps on this selectable text (not link)
final GestureTapCallback? onTap;
final ScrollPhysics? scrollPhysics;
/// Defines how the paragraph will apply TextStyle.height to the ascent of
/// the first line and descent of the last line.
final TextHeightBehavior? textHeightBehavior;
/// How tall the cursor will be.
final double? cursorHeight;
/// Optional delegate for building the text selection handles and toolbar.
final TextSelectionControls? selectionControls;
/// Called when the user changes the selection of text (including the
/// cursor location).
final SelectionChangedCallback? onSelectionChanged;
@override
Widget build(BuildContext context) {
final List<LinkifyElement> elements = LinkifierUtil.linkify(text);
return SelectableText.rich(
buildTextSpan(
elements,
style: Theme.of(context).textTheme.bodyMedium?.merge(style),
onOpen: onOpen,
linkStyle: Theme.of(context)
.textTheme
.bodyMedium
?.merge(style)
.copyWith(
color: Colors.blueAccent,
decoration: TextDecoration.underline,
)
.merge(linkStyle),
),
textAlign: textAlign,
textDirection: textDirection,
minLines: minLines,
maxLines: maxLines,
focusNode: focusNode,
strutStyle: strutStyle,
showCursor: showCursor,
textScaleFactor: textScaleFactor,
autofocus: autofocus,
cursorWidth: cursorWidth,
cursorRadius: cursorRadius,
cursorColor: cursorColor,
dragStartBehavior: dragStartBehavior,
enableInteractiveSelection: enableInteractiveSelection,
onTap: onTap,
scrollPhysics: scrollPhysics,
textWidthBasis: textWidthBasis,
textHeightBehavior: textHeightBehavior,
cursorHeight: cursorHeight,
selectionControls: selectionControls,
onSelectionChanged: onSelectionChanged,
);
}
}
class LinkableSpan extends WidgetSpan {
LinkableSpan({
required MouseCursor mouseCursor,
required InlineSpan inlineSpan,
}) : super(
child: MouseRegion(
cursor: mouseCursor,
child: Text.rich(
inlineSpan,
),
),
);
}
/// Raw TextSpan builder for more control on the RichText
TextSpan buildTextSpan(
List<LinkifyElement> elements, {
TextStyle? style,
TextStyle? linkStyle,
LinkCallback? onOpen,
bool useMouseRegion = false,
}) {
return TextSpan(
children: elements.map<InlineSpan>(
(LinkifyElement element) {
if (element is LinkableElement) {
if (useMouseRegion) {
return LinkableSpan(
mouseCursor: SystemMouseCursors.click,
inlineSpan: TextSpan(
text: element.text,
style: linkStyle,
recognizer: onOpen != null
? (TapGestureRecognizer()..onTap = () => onOpen(element))
: null,
),
);
} else {
return TextSpan(
text: element.text,
style: linkStyle,
recognizer: onOpen != null
? (TapGestureRecognizer()..onTap = () => onOpen(element))
: null,
);
}
} else {
if (element is QuoteElement) {
return TextSpan(
text: element.text,
style: style?.copyWith(
backgroundColor: Palette.orangeAccent.withOpacity(0.3),
),
);
} else if (element is EmphasisElement) {
return TextSpan(
text: element.text,
style: style?.copyWith(
fontStyle: FontStyle.italic,
),
);
}
return TextSpan(
text: element.text,
style: style,
);
}
},
).toList(),
);
}

View File

@ -0,0 +1,77 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
final RegExp _emphasisRegex = RegExp(
r'\*(.*?)\*',
multiLine: true,
);
class EmphasisLinkifier extends Linkifier {
const EmphasisLinkifier();
@override
List<LinkifyElement> parse(
List<LinkifyElement> elements,
LinkifyOptions options,
) {
final List<LinkifyElement> list = <LinkifyElement>[];
for (final LinkifyElement element in elements) {
if (element is TextElement) {
final RegExpMatch? match = _emphasisRegex.firstMatch(
element.text.trimLeft(),
);
if (element.text == '* * *' ||
match == null ||
match.group(0) == null ||
match.group(1) == null) {
list.add(element);
} else {
final String matchedText = match.group(1)!;
final num pos =
(element.text.indexOf(matchedText) - 1).clamp(0, double.infinity);
final List<String> splitTexts = element.text.split(match.group(0)!);
int curPos = 0;
bool added = false;
for (final String text in splitTexts) {
list.addAll(parse(<LinkifyElement>[TextElement(text)], options));
curPos += text.length;
if (!added && curPos >= pos) {
added = true;
list.add(EmphasisElement(matchedText));
}
}
}
} else {
list.add(element);
}
}
return list;
}
}
/// Represents an element wrapped around '*'.
@immutable
class EmphasisElement extends LinkifyElement {
EmphasisElement(super.text);
@override
String toString() {
return "EmphasisElement: '$text'";
}
@override
bool operator ==(Object other) => equals(other);
@override
bool equals(dynamic other) => other is EmphasisElement && super.equals(other);
@override
int get hashCode => text.hashCode;
}

View File

@ -0,0 +1,2 @@
export 'emphasis_linkifier.dart';
export 'quote_linkifier.dart';

View File

@ -0,0 +1,71 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
final RegExp _quoteRegex = RegExp(
r'(?=^> )(.*?)(?=\n|$)',
multiLine: true,
);
class QuoteLinkifier extends Linkifier {
const QuoteLinkifier();
@override
List<LinkifyElement> parse(
List<LinkifyElement> elements,
LinkifyOptions options,
) {
final List<LinkifyElement> list = <LinkifyElement>[];
for (final LinkifyElement element in elements) {
if (element is TextElement) {
final RegExpMatch? match = _quoteRegex.firstMatch(
element.text.trimLeft(),
);
if (match == null) {
list.add(element);
} else {
final String matchedText = match.group(0)!;
final int pos = element.text.indexOf(matchedText);
final List<String> splitTexts = element.text.split(matchedText);
int curPos = 0;
bool added = false;
for (final String text in splitTexts) {
list.addAll(parse(<TextElement>[TextElement(text)], options));
curPos += text.length;
if (!added && curPos >= pos) {
added = true;
list.add(QuoteElement(matchedText));
}
}
}
} else {
list.add(element);
}
}
return list;
}
}
/// Represents an element that starts with '>'.
@immutable
class QuoteElement extends LinkifyElement {
QuoteElement(super.text);
@override
String toString() {
return "QuoteElement: '$text'";
}
@override
bool operator ==(Object other) => equals(other);
@override
bool equals(dynamic other) => other is QuoteElement && super.equals(other);
@override
int get hashCode => text.hashCode;
}

View File

@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/cubits/cubits.dart';

View File

@ -79,9 +79,8 @@ class StoryTile extends StatelessWidget {
Row(
children: <Widget>[
Expanded(
child: RichText(
textScaleFactor: MediaQuery.of(context).textScaleFactor,
text: TextSpan(
child: Text.rich(
TextSpan(
children: <TextSpan>[
TextSpan(
text: story.title,
@ -105,6 +104,7 @@ class StoryTile extends StatelessWidget {
),
],
),
textScaleFactor: MediaQuery.of(context).textScaleFactor,
),
),
],

View File

@ -6,6 +6,7 @@ export 'countdown_reminder.dart';
export 'custom_chip.dart';
export 'custom_circular_progress_indicator.dart';
export 'custom_described_feature_overlay.dart';
export 'custom_linkify/custom_linkify.dart';
export 'custom_tab_bar.dart';
export 'items_list_view.dart';
export 'link_preview/link_preview.dart';

View File

@ -0,0 +1,29 @@
import 'package:hacki/screens/widgets/custom_linkify/linkifiers/linkifiers.dart';
import 'package:linkify/linkify.dart';
abstract class LinkifierUtil {
static List<LinkifyElement> linkify(String text) {
const LinkifyOptions options = LinkifyOptions();
const List<Linkifier> linkifiers = <Linkifier>[
UrlLinkifier(),
EmailLinkifier(),
QuoteLinkifier(),
EmphasisLinkifier(),
];
List<LinkifyElement> list = <LinkifyElement>[TextElement(text)];
if (text.isEmpty) {
return <LinkifyElement>[];
}
if (linkifiers.isEmpty) {
return list;
}
for (final Linkifier linkifier in linkifiers) {
list = linkifier.parse(list, options);
}
return list;
}
}

View File

@ -1,6 +1,7 @@
export 'debouncer.dart';
export 'html_util.dart';
export 'link_util.dart';
export 'linkifier_util.dart';
export 'log_util.dart';
export 'service_exception.dart';
export 'throttle.dart';

View File

@ -569,7 +569,7 @@ packages:
source: hosted
version: "0.6.5"
linkify:
dependency: transitive
dependency: "direct main"
description:
name: linkify
sha256: bdfbdafec6cdc9cd0ebb333a868cafc046714ad508e48be8095208c54691d959

View File

@ -1,6 +1,6 @@
name: hacki
description: A Hacker News reader.
version: 1.0.12+90
version: 1.1.0+91
publish_to: none
environment:
@ -44,6 +44,7 @@ dependencies:
http: ^0.13.5
hydrated_bloc: ^9.1.0
intl: ^0.18.0
linkify: ^4.1.0
logger: ^1.1.0
package_info_plus: ^3.0.3
path: ^1.8.2
@ -90,4 +91,21 @@ flutter:
assets:
- assets/images/
fonts:
- family: RobotoSlab
fonts:
- asset: assets/fonts/roboto_slab/RobotoSlab-Regular.ttf
- asset: assets/fonts/roboto_slab/RobotoSlab-Bold.ttf
weight: 700
- family: Ubuntu
fonts:
- asset: assets/fonts/ubuntu/Ubuntu-Regular.ttf
- asset: assets/fonts/ubuntu/Ubuntu-Bold.ttf
weight: 700
- family: UbuntuMono
fonts:
- asset: assets/fonts/ubuntu_mono/UbuntuMono-Regular.ttf
- asset: assets/fonts/ubuntu_mono/UbuntuMono-Bold.ttf
weight: 700