mirror of
https://github.com/Livinglist/Hacki.git
synced 2025-08-06 18:24:42 +08:00
Compare commits
6 Commits
Author | SHA1 | Date | |
---|---|---|---|
e5e3391785 | |||
9159fe0fe1 | |||
7c51bad35e | |||
6836138d11 | |||
2f71964277 | |||
c24c5c1b7a |
BIN
assets/fonts/roboto_slab/RobotoSlab-Bold.ttf
Normal file
BIN
assets/fonts/roboto_slab/RobotoSlab-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/roboto_slab/RobotoSlab-Regular.ttf
Normal file
BIN
assets/fonts/roboto_slab/RobotoSlab-Regular.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/ubuntu/Ubuntu-Bold.ttf
Normal file
BIN
assets/fonts/ubuntu/Ubuntu-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/ubuntu/Ubuntu-Regular.ttf
Normal file
BIN
assets/fonts/ubuntu/Ubuntu-Regular.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/ubuntu_mono/UbuntuMono-Bold.ttf
Normal file
BIN
assets/fonts/ubuntu_mono/UbuntuMono-Bold.ttf
Normal file
Binary file not shown.
BIN
assets/fonts/ubuntu_mono/UbuntuMono-Regular.ttf
Normal file
BIN
assets/fonts/ubuntu_mono/UbuntuMono-Regular.ttf
Normal file
Binary file not shown.
5
fastlane/metadata/android/en-US/changelogs/91.txt
Normal file
5
fastlane/metadata/android/en-US/changelogs/91.txt
Normal 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.
|
5
fastlane/metadata/android/en-US/changelogs/92.txt
Normal file
5
fastlane/metadata/android/en-US/changelogs/92.txt
Normal 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.
|
@ -16,6 +16,8 @@ abstract class Constants {
|
|||||||
'https://news.ycombinator.com/newsguidelines.html';
|
'https://news.ycombinator.com/newsguidelines.html';
|
||||||
static const String githubIssueLink =
|
static const String githubIssueLink =
|
||||||
'$githubLink/issues/new?title=Found+a+bug+in+Hacki&body=Please+describe+the+problem.';
|
'$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 supportEmail = 'georgefung98@gmail.com';
|
||||||
|
|
||||||
static const String _imagePath = 'assets/images';
|
static const String _imagePath = 'assets/images';
|
||||||
|
@ -3,15 +3,18 @@ import 'dart:async';
|
|||||||
import 'package:bloc/bloc.dart';
|
import 'package:bloc/bloc.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
|
||||||
import 'package:hacki/config/locator.dart';
|
import 'package:hacki/config/locator.dart';
|
||||||
import 'package:hacki/main.dart';
|
import 'package:hacki/main.dart';
|
||||||
import 'package:hacki/models/models.dart';
|
import 'package:hacki/models/models.dart';
|
||||||
import 'package:hacki/repositories/repositories.dart';
|
import 'package:hacki/repositories/repositories.dart';
|
||||||
import 'package:hacki/screens/screens.dart';
|
import 'package:hacki/screens/screens.dart';
|
||||||
import 'package:hacki/services/services.dart';
|
import 'package:hacki/services/services.dart';
|
||||||
|
import 'package:hacki/utils/linkifier_util.dart';
|
||||||
|
import 'package:linkify/linkify.dart';
|
||||||
import 'package:logger/logger.dart';
|
import 'package:logger/logger.dart';
|
||||||
|
import 'package:rxdart/rxdart.dart';
|
||||||
|
|
||||||
part 'comments_state.dart';
|
part 'comments_state.dart';
|
||||||
|
|
||||||
@ -89,6 +92,8 @@ class CommentsCubit extends Cubit<CommentsState> {
|
|||||||
ids: targetParents!.last.kids,
|
ids: targetParents!.last.kids,
|
||||||
level: targetParents.last.level + 1,
|
level: targetParents.last.level + 1,
|
||||||
)
|
)
|
||||||
|
.asyncMap(_toBuildableComment)
|
||||||
|
.whereNotNull()
|
||||||
.listen(_onCommentFetched)
|
.listen(_onCommentFetched)
|
||||||
..onDone(_onDone);
|
..onDone(_onDone);
|
||||||
|
|
||||||
@ -111,33 +116,32 @@ class CommentsCubit extends Cubit<CommentsState> {
|
|||||||
|
|
||||||
emit(state.copyWith(item: updatedItem));
|
emit(state.copyWith(item: updatedItem));
|
||||||
|
|
||||||
|
late final Stream<Comment> commentStream;
|
||||||
|
|
||||||
if (state.offlineReading) {
|
if (state.offlineReading) {
|
||||||
_streamSubscription = _offlineRepository
|
commentStream = _offlineRepository.getCachedCommentsStream(ids: kids);
|
||||||
.getCachedCommentsStream(ids: kids)
|
|
||||||
.listen(_onCommentFetched)
|
|
||||||
..onDone(_onDone);
|
|
||||||
} else {
|
} else {
|
||||||
switch (state.fetchMode) {
|
switch (state.fetchMode) {
|
||||||
case FetchMode.lazy:
|
case FetchMode.lazy:
|
||||||
_streamSubscription = _storiesRepository
|
commentStream = _storiesRepository.fetchCommentsStream(
|
||||||
.fetchCommentsStream(
|
ids: kids,
|
||||||
ids: kids,
|
getFromCache: useCommentCache ? _commentCache.getComment : null,
|
||||||
getFromCache: useCommentCache ? _commentCache.getComment : null,
|
);
|
||||||
)
|
|
||||||
.listen(_onCommentFetched)
|
|
||||||
..onDone(_onDone);
|
|
||||||
break;
|
break;
|
||||||
case FetchMode.eager:
|
case FetchMode.eager:
|
||||||
_streamSubscription = _storiesRepository
|
commentStream = _storiesRepository.fetchAllCommentsRecursivelyStream(
|
||||||
.fetchAllCommentsRecursivelyStream(
|
ids: kids,
|
||||||
ids: kids,
|
getFromCache: useCommentCache ? _commentCache.getComment : null,
|
||||||
getFromCache: useCommentCache ? _commentCache.getComment : null,
|
);
|
||||||
)
|
|
||||||
.listen(_onCommentFetched)
|
|
||||||
..onDone(_onDone);
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_streamSubscription = commentStream
|
||||||
|
.asyncMap(_toBuildableComment)
|
||||||
|
.whereNotNull()
|
||||||
|
.listen(_onCommentFetched)
|
||||||
|
..onDone(_onDone);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> refresh() async {
|
Future<void> refresh() async {
|
||||||
@ -176,22 +180,23 @@ class CommentsCubit extends Cubit<CommentsState> {
|
|||||||
await _storiesRepository.fetchItem(id: item.id) ?? item;
|
await _storiesRepository.fetchItem(id: item.id) ?? item;
|
||||||
final List<int> kids = sortKids(updatedItem.kids);
|
final List<int> kids = sortKids(updatedItem.kids);
|
||||||
|
|
||||||
|
late final Stream<Comment> commentStream;
|
||||||
if (state.fetchMode == FetchMode.lazy) {
|
if (state.fetchMode == FetchMode.lazy) {
|
||||||
_streamSubscription = _storiesRepository
|
commentStream = _storiesRepository.fetchCommentsStream(
|
||||||
.fetchCommentsStream(
|
ids: kids,
|
||||||
ids: kids,
|
);
|
||||||
)
|
|
||||||
.listen(_onCommentFetched)
|
|
||||||
..onDone(_onDone);
|
|
||||||
} else {
|
} else {
|
||||||
_streamSubscription = _storiesRepository
|
commentStream = _storiesRepository.fetchAllCommentsRecursivelyStream(
|
||||||
.fetchAllCommentsRecursivelyStream(
|
ids: kids,
|
||||||
ids: kids,
|
);
|
||||||
)
|
|
||||||
.listen(_onCommentFetched)
|
|
||||||
..onDone(_onDone);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_streamSubscription = commentStream
|
||||||
|
.asyncMap(_toBuildableComment)
|
||||||
|
.whereNotNull()
|
||||||
|
.listen(_onCommentFetched)
|
||||||
|
..onDone(_onDone);
|
||||||
|
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
item: updatedItem,
|
item: updatedItem,
|
||||||
@ -227,23 +232,18 @@ class CommentsCubit extends Cubit<CommentsState> {
|
|||||||
final StreamSubscription<Comment> streamSubscription =
|
final StreamSubscription<Comment> streamSubscription =
|
||||||
_storiesRepository
|
_storiesRepository
|
||||||
.fetchCommentsStream(ids: comment.kids)
|
.fetchCommentsStream(ids: comment.kids)
|
||||||
|
.asyncMap(_toBuildableComment)
|
||||||
|
.whereNotNull()
|
||||||
.listen((Comment cmt) {
|
.listen((Comment cmt) {
|
||||||
_collapseCache.addKid(cmt.id, to: cmt.parent);
|
_collapseCache.addKid(cmt.id, to: cmt.parent);
|
||||||
_commentCache.cacheComment(cmt);
|
_commentCache.cacheComment(cmt);
|
||||||
_sembastRepository.cacheComment(cmt);
|
_sembastRepository.cacheComment(cmt);
|
||||||
|
|
||||||
final List<LinkifyElement> elements = _linkify(
|
|
||||||
cmt.text,
|
|
||||||
);
|
|
||||||
|
|
||||||
final BuildableComment buildableComment =
|
|
||||||
BuildableComment.fromComment(cmt, elements: elements);
|
|
||||||
|
|
||||||
emit(
|
emit(
|
||||||
state.copyWith(
|
state.copyWith(
|
||||||
comments: <Comment>[...state.comments]..insert(
|
comments: <Comment>[...state.comments]..insert(
|
||||||
state.comments.indexOf(comment) + offset + 1,
|
state.comments.indexOf(comment) + offset + 1,
|
||||||
buildableComment.copyWith(level: level),
|
cmt.copyWith(level: level),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -340,22 +340,15 @@ class CommentsCubit extends Cubit<CommentsState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onCommentFetched(Comment? comment) {
|
void _onCommentFetched(BuildableComment? comment) {
|
||||||
if (comment != null) {
|
if (comment != null) {
|
||||||
_collapseCache.addKid(comment.id, to: comment.parent);
|
_collapseCache.addKid(comment.id, to: comment.parent);
|
||||||
_commentCache.cacheComment(comment);
|
_commentCache.cacheComment(comment);
|
||||||
_sembastRepository.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>[
|
final List<Comment> updatedComments = <Comment>[
|
||||||
...state.comments,
|
...state.comments,
|
||||||
buildableComment
|
comment
|
||||||
];
|
];
|
||||||
|
|
||||||
emit(state.copyWith(comments: updatedComments));
|
emit(state.copyWith(comments: updatedComments));
|
||||||
@ -387,29 +380,19 @@ class CommentsCubit extends Cubit<CommentsState> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<LinkifyElement> _linkify(
|
static Future<BuildableComment?> _toBuildableComment(Comment? comment) async {
|
||||||
String text, {
|
if (comment == null) return null;
|
||||||
LinkifyOptions options = const LinkifyOptions(),
|
|
||||||
List<Linkifier> linkifiers = const <Linkifier>[
|
|
||||||
UrlLinkifier(),
|
|
||||||
EmailLinkifier(),
|
|
||||||
],
|
|
||||||
}) {
|
|
||||||
List<LinkifyElement> list = <LinkifyElement>[TextElement(text)];
|
|
||||||
|
|
||||||
if (text.isEmpty) {
|
final List<LinkifyElement> elements =
|
||||||
return <LinkifyElement>[];
|
await compute<String, List<LinkifyElement>>(
|
||||||
}
|
LinkifierUtil.linkify,
|
||||||
|
comment.text,
|
||||||
|
);
|
||||||
|
|
||||||
if (linkifiers.isEmpty) {
|
final BuildableComment buildableComment =
|
||||||
return list;
|
BuildableComment.fromComment(comment, elements: elements);
|
||||||
}
|
|
||||||
|
|
||||||
for (final Linkifier linkifier in linkifiers) {
|
return buildableComment;
|
||||||
list = linkifier.parse(list, options);
|
|
||||||
}
|
|
||||||
|
|
||||||
return list;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -96,6 +96,9 @@ class PreferenceState extends Equatable {
|
|||||||
FontSize get fontSize => FontSize.values
|
FontSize get fontSize => FontSize.values
|
||||||
.elementAt(preferences.singleWhereType<FontSizePreference>().val);
|
.elementAt(preferences.singleWhereType<FontSizePreference>().val);
|
||||||
|
|
||||||
|
Font get font =>
|
||||||
|
Font.values.elementAt(preferences.singleWhereType<FontPreference>().val);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => <Object?>[
|
List<Object?> get props => <Object?>[
|
||||||
...preferences.map<dynamic>((Preference<dynamic> e) => e.val),
|
...preferences.map<dynamic>((Preference<dynamic> e) => e.val),
|
||||||
|
@ -1,4 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
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 {
|
extension WidgetModifier on Widget {
|
||||||
Widget padded([EdgeInsetsGeometry value = const EdgeInsets.all(12)]) {
|
Widget padded([EdgeInsetsGeometry value = const EdgeInsets.all(12)]) {
|
||||||
@ -7,4 +11,59 @@ extension WidgetModifier on Widget {
|
|||||||
child: this,
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -128,6 +128,9 @@ Future<void> main({bool testing = false}) async {
|
|||||||
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
final SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||||
final bool trueDarkMode =
|
final bool trueDarkMode =
|
||||||
prefs.getBool(const TrueDarkModePreference().key) ?? false;
|
prefs.getBool(const TrueDarkModePreference().key) ?? false;
|
||||||
|
final Font font = Font.values.elementAt(
|
||||||
|
prefs.getInt(FontPreference().key) ?? Font.roboto.index,
|
||||||
|
);
|
||||||
|
|
||||||
Bloc.observer = CustomBlocObserver();
|
Bloc.observer = CustomBlocObserver();
|
||||||
|
|
||||||
@ -137,6 +140,7 @@ Future<void> main({bool testing = false}) async {
|
|||||||
HackiApp(
|
HackiApp(
|
||||||
savedThemeMode: savedThemeMode,
|
savedThemeMode: savedThemeMode,
|
||||||
trueDarkMode: trueDarkMode,
|
trueDarkMode: trueDarkMode,
|
||||||
|
font: font,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -146,9 +150,11 @@ class HackiApp extends StatelessWidget {
|
|||||||
super.key,
|
super.key,
|
||||||
this.savedThemeMode,
|
this.savedThemeMode,
|
||||||
required this.trueDarkMode,
|
required this.trueDarkMode,
|
||||||
|
required this.font,
|
||||||
});
|
});
|
||||||
|
|
||||||
final AdaptiveThemeMode? savedThemeMode;
|
final AdaptiveThemeMode? savedThemeMode;
|
||||||
|
final Font font;
|
||||||
final bool trueDarkMode;
|
final bool trueDarkMode;
|
||||||
|
|
||||||
static final GlobalKey<NavigatorState> navigatorKey =
|
static final GlobalKey<NavigatorState> navigatorKey =
|
||||||
@ -227,11 +233,13 @@ class HackiApp extends StatelessWidget {
|
|||||||
child: AdaptiveTheme(
|
child: AdaptiveTheme(
|
||||||
light: ThemeData(
|
light: ThemeData(
|
||||||
primarySwatch: Palette.orange,
|
primarySwatch: Palette.orange,
|
||||||
|
fontFamily: font.name,
|
||||||
),
|
),
|
||||||
dark: ThemeData(
|
dark: ThemeData(
|
||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
primarySwatch: Palette.orange,
|
primarySwatch: Palette.orange,
|
||||||
canvasColor: trueDarkMode ? Palette.black : null,
|
canvasColor: trueDarkMode ? Palette.black : null,
|
||||||
|
fontFamily: font.name,
|
||||||
),
|
),
|
||||||
initial: savedThemeMode ?? AdaptiveThemeMode.system,
|
initial: savedThemeMode ?? AdaptiveThemeMode.system,
|
||||||
builder: (ThemeData theme, ThemeData darkTheme) {
|
builder: (ThemeData theme, ThemeData darkTheme) {
|
||||||
@ -239,6 +247,7 @@ class HackiApp extends StatelessWidget {
|
|||||||
brightness: Brightness.dark,
|
brightness: Brightness.dark,
|
||||||
primarySwatch: Palette.orange,
|
primarySwatch: Palette.orange,
|
||||||
canvasColor: Palette.black,
|
canvasColor: Palette.black,
|
||||||
|
fontFamily: font.name,
|
||||||
);
|
);
|
||||||
return FutureBuilder<AdaptiveThemeMode?>(
|
return FutureBuilder<AdaptiveThemeMode?>(
|
||||||
future: AdaptiveTheme.getThemeMode(),
|
future: AdaptiveTheme.getThemeMode(),
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
|
||||||
import 'package:hacki/models/comment.dart';
|
import 'package:hacki/models/comment.dart';
|
||||||
import 'package:hacki/models/models.dart';
|
import 'package:hacki/models/models.dart';
|
||||||
|
import 'package:linkify/linkify.dart';
|
||||||
|
|
||||||
/// [BuildableComment] is a subtype of [Comment] which stores
|
/// [BuildableComment] is a subtype of [Comment] which stores
|
||||||
/// the corresponding [LinkifyElement] for faster widget building.
|
/// the corresponding [LinkifyElement] for faster widget building.
|
||||||
|
10
lib/models/font.dart
Normal file
10
lib/models/font.dart
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
enum Font {
|
||||||
|
roboto('Roboto'),
|
||||||
|
robotoSlab('Roboto Slab'),
|
||||||
|
ubuntu('Ubuntu'),
|
||||||
|
ubuntuMono('Ubuntu Mono');
|
||||||
|
|
||||||
|
const Font(this.label);
|
||||||
|
|
||||||
|
final String label;
|
||||||
|
}
|
@ -2,6 +2,7 @@ export 'buildable_comment.dart';
|
|||||||
export 'comment.dart';
|
export 'comment.dart';
|
||||||
export 'comments_order.dart';
|
export 'comments_order.dart';
|
||||||
export 'fetch_mode.dart';
|
export 'fetch_mode.dart';
|
||||||
|
export 'font.dart';
|
||||||
export 'font_size.dart';
|
export 'font_size.dart';
|
||||||
export 'item.dart';
|
export 'item.dart';
|
||||||
export 'poll_option.dart';
|
export 'poll_option.dart';
|
||||||
|
@ -20,6 +20,7 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
|
|||||||
// Order of these first four preferences does not matter.
|
// Order of these first four preferences does not matter.
|
||||||
FetchModePreference(),
|
FetchModePreference(),
|
||||||
CommentsOrderPreference(),
|
CommentsOrderPreference(),
|
||||||
|
FontPreference(),
|
||||||
FontSizePreference(),
|
FontSizePreference(),
|
||||||
TabOrderPreference(),
|
TabOrderPreference(),
|
||||||
// Order of items below matters and
|
// Order of items below matters and
|
||||||
@ -65,6 +66,7 @@ const bool _collapseModeDefaultValue = true;
|
|||||||
final int _fetchModeDefaultValue = FetchMode.eager.index;
|
final int _fetchModeDefaultValue = FetchMode.eager.index;
|
||||||
final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
|
final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
|
||||||
final int _fontSizeDefaultValue = FontSize.regular.index;
|
final int _fontSizeDefaultValue = FontSize.regular.index;
|
||||||
|
final int _fontDefaultValue = Font.roboto.index;
|
||||||
final int _tabOrderDefaultValue =
|
final int _tabOrderDefaultValue =
|
||||||
StoryType.convertToSettingsValue(StoryType.values);
|
StoryType.convertToSettingsValue(StoryType.values);
|
||||||
|
|
||||||
@ -325,6 +327,21 @@ class CommentsOrderPreference extends IntPreference {
|
|||||||
String get title => 'Default comments order';
|
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 {
|
class FontSizePreference extends IntPreference {
|
||||||
FontSizePreference({int? val}) : super(val: val ?? _fontSizeDefaultValue);
|
FontSizePreference({int? val}) : super(val: val ?? _fontSizeDefaultValue);
|
||||||
|
|
||||||
|
@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_fadein/flutter_fadein.dart';
|
import 'package:flutter_fadein/flutter_fadein.dart';
|
||||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
|
||||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||||
import 'package:hacki/blocs/blocs.dart';
|
import 'package:hacki/blocs/blocs.dart';
|
||||||
import 'package:hacki/config/constants.dart';
|
import 'package:hacki/config/constants.dart';
|
||||||
@ -340,12 +339,8 @@ class _ParentItemSection extends StatelessWidget {
|
|||||||
bottom: Dimens.pt12,
|
bottom: Dimens.pt12,
|
||||||
top: Dimens.pt12,
|
top: Dimens.pt12,
|
||||||
),
|
),
|
||||||
child: RichText(
|
child: Text.rich(
|
||||||
textAlign: TextAlign.center,
|
TextSpan(
|
||||||
textScaleFactor: MediaQuery.of(
|
|
||||||
context,
|
|
||||||
).textScaleFactor,
|
|
||||||
text: TextSpan(
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
fontSize: prefState.fontSize.fontSize,
|
fontSize: prefState.fontSize.fontSize,
|
||||||
@ -378,6 +373,10 @@ class _ParentItemSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
textScaleFactor: MediaQuery.of(
|
||||||
|
context,
|
||||||
|
).textScaleFactor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
|
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
|
||||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
|
||||||
import 'package:hacki/blocs/blocs.dart';
|
import 'package:hacki/blocs/blocs.dart';
|
||||||
import 'package:hacki/cubits/cubits.dart';
|
import 'package:hacki/cubits/cubits.dart';
|
||||||
import 'package:hacki/extensions/extensions.dart';
|
import 'package:hacki/extensions/extensions.dart';
|
||||||
import 'package:hacki/models/models.dart';
|
import 'package:hacki/models/models.dart';
|
||||||
import 'package:hacki/screens/item/models/models.dart';
|
import 'package:hacki/screens/item/models/models.dart';
|
||||||
|
import 'package:hacki/screens/widgets/widgets.dart';
|
||||||
import 'package:hacki/styles/styles.dart';
|
import 'package:hacki/styles/styles.dart';
|
||||||
import 'package:hacki/utils/utils.dart';
|
import 'package:hacki/utils/utils.dart';
|
||||||
|
|
||||||
|
@ -3,11 +3,11 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
|
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
|
||||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
|
||||||
import 'package:hacki/cubits/cubits.dart';
|
import 'package:hacki/cubits/cubits.dart';
|
||||||
import 'package:hacki/extensions/extensions.dart';
|
import 'package:hacki/extensions/extensions.dart';
|
||||||
import 'package:hacki/models/item.dart';
|
import 'package:hacki/models/item.dart';
|
||||||
import 'package:hacki/screens/screens.dart';
|
import 'package:hacki/screens/screens.dart';
|
||||||
|
import 'package:hacki/screens/widgets/widgets.dart';
|
||||||
import 'package:hacki/styles/styles.dart';
|
import 'package:hacki/styles/styles.dart';
|
||||||
import 'package:hacki/utils/link_util.dart';
|
import 'package:hacki/utils/link_util.dart';
|
||||||
|
|
||||||
|
@ -1,6 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_fadein/flutter_fadein.dart';
|
import 'package:flutter_fadein/flutter_fadein.dart';
|
||||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
|
||||||
import 'package:hacki/models/models.dart';
|
import 'package:hacki/models/models.dart';
|
||||||
import 'package:hacki/screens/widgets/widgets.dart';
|
import 'package:hacki/screens/widgets/widgets.dart';
|
||||||
import 'package:hacki/styles/styles.dart';
|
import 'package:hacki/styles/styles.dart';
|
||||||
|
@ -219,6 +219,12 @@ class _SettingsState extends State<Settings> {
|
|||||||
},
|
},
|
||||||
activeColor: Palette.orange,
|
activeColor: Palette.orange,
|
||||||
),
|
),
|
||||||
|
ListTile(
|
||||||
|
title: const Text(
|
||||||
|
'Font',
|
||||||
|
),
|
||||||
|
onTap: showFontSettingDialog,
|
||||||
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
title: const Text(
|
title: const Text(
|
||||||
'Theme',
|
'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() {
|
void showThemeSettingDialog() {
|
||||||
showDialog<void>(
|
showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
|
@ -1,14 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
|
||||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||||
import 'package:hacki/blocs/blocs.dart';
|
import 'package:hacki/blocs/blocs.dart';
|
||||||
import 'package:hacki/cubits/cubits.dart';
|
import 'package:hacki/cubits/cubits.dart';
|
||||||
import 'package:hacki/extensions/extensions.dart';
|
import 'package:hacki/extensions/extensions.dart';
|
||||||
import 'package:hacki/models/models.dart';
|
import 'package:hacki/models/models.dart';
|
||||||
import 'package:hacki/screens/widgets/bloc_builder_3.dart';
|
import 'package:hacki/screens/widgets/widgets.dart';
|
||||||
import 'package:hacki/screens/widgets/centered_text.dart';
|
|
||||||
import 'package:hacki/services/services.dart';
|
import 'package:hacki/services/services.dart';
|
||||||
import 'package:hacki/styles/styles.dart';
|
import 'package:hacki/styles/styles.dart';
|
||||||
import 'package:hacki/utils/utils.dart';
|
import 'package:hacki/utils/utils.dart';
|
||||||
@ -366,6 +364,15 @@ class _CommentText extends StatelessWidget {
|
|||||||
onOpen: onLinkTapped,
|
onOpen: onLinkTapped,
|
||||||
),
|
),
|
||||||
onTap: () => onTextTapped(context),
|
onTap: () => onTextTapped(context),
|
||||||
|
contextMenuBuilder: (
|
||||||
|
BuildContext context,
|
||||||
|
EditableTextState editableTextState,
|
||||||
|
) =>
|
||||||
|
contextMenuBuilder(
|
||||||
|
context,
|
||||||
|
editableTextState,
|
||||||
|
comment: comment as BuildableComment,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
return SelectableLinkify(
|
return SelectableLinkify(
|
||||||
|
390
lib/screens/widgets/custom_linkify/custom_linkify.dart
Normal file
390
lib/screens/widgets/custom_linkify/custom_linkify.dart
Normal 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(),
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,77 @@
|
|||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:linkify/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;
|
||||||
|
}
|
@ -0,0 +1,2 @@
|
|||||||
|
export 'emphasis_linkifier.dart';
|
||||||
|
export 'quote_linkifier.dart';
|
@ -0,0 +1,71 @@
|
|||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:linkify/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;
|
||||||
|
}
|
@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_fadein/flutter_fadein.dart';
|
import 'package:flutter_fadein/flutter_fadein.dart';
|
||||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
|
||||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||||
import 'package:hacki/blocs/blocs.dart';
|
import 'package:hacki/blocs/blocs.dart';
|
||||||
import 'package:hacki/cubits/cubits.dart';
|
import 'package:hacki/cubits/cubits.dart';
|
||||||
|
@ -79,9 +79,8 @@ class StoryTile extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Expanded(
|
Expanded(
|
||||||
child: RichText(
|
child: Text.rich(
|
||||||
textScaleFactor: MediaQuery.of(context).textScaleFactor,
|
TextSpan(
|
||||||
text: TextSpan(
|
|
||||||
children: <TextSpan>[
|
children: <TextSpan>[
|
||||||
TextSpan(
|
TextSpan(
|
||||||
text: story.title,
|
text: story.title,
|
||||||
@ -105,6 +104,7 @@ class StoryTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
textScaleFactor: MediaQuery.of(context).textScaleFactor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
@ -6,6 +6,7 @@ export 'countdown_reminder.dart';
|
|||||||
export 'custom_chip.dart';
|
export 'custom_chip.dart';
|
||||||
export 'custom_circular_progress_indicator.dart';
|
export 'custom_circular_progress_indicator.dart';
|
||||||
export 'custom_described_feature_overlay.dart';
|
export 'custom_described_feature_overlay.dart';
|
||||||
|
export 'custom_linkify/custom_linkify.dart';
|
||||||
export 'custom_tab_bar.dart';
|
export 'custom_tab_bar.dart';
|
||||||
export 'items_list_view.dart';
|
export 'items_list_view.dart';
|
||||||
export 'link_preview/link_preview.dart';
|
export 'link_preview/link_preview.dart';
|
||||||
|
29
lib/utils/linkifier_util.dart
Normal file
29
lib/utils/linkifier_util.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
export 'debouncer.dart';
|
export 'debouncer.dart';
|
||||||
export 'html_util.dart';
|
export 'html_util.dart';
|
||||||
export 'link_util.dart';
|
export 'link_util.dart';
|
||||||
|
export 'linkifier_util.dart';
|
||||||
export 'log_util.dart';
|
export 'log_util.dart';
|
||||||
export 'service_exception.dart';
|
export 'service_exception.dart';
|
||||||
export 'throttle.dart';
|
export 'throttle.dart';
|
||||||
|
12
pubspec.lock
12
pubspec.lock
@ -332,14 +332,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.7.2+3"
|
version: "5.7.2+3"
|
||||||
flutter_linkify:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: flutter_linkify
|
|
||||||
sha256: c89fe74de985ec22f23d3538d2249add085a4f37ac1c29fd79e1a207efb81d63
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "5.0.2"
|
|
||||||
flutter_local_notifications:
|
flutter_local_notifications:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@ -569,7 +561,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "0.6.5"
|
version: "0.6.5"
|
||||||
linkify:
|
linkify:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: linkify
|
name: linkify
|
||||||
sha256: bdfbdafec6cdc9cd0ebb333a868cafc046714ad508e48be8095208c54691d959
|
sha256: bdfbdafec6cdc9cd0ebb333a868cafc046714ad508e48be8095208c54691d959
|
||||||
@ -1359,4 +1351,4 @@ packages:
|
|||||||
version: "3.1.1"
|
version: "3.1.1"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=2.19.0 <3.0.0"
|
dart: ">=2.19.0 <3.0.0"
|
||||||
flutter: ">=3.7.3"
|
flutter: ">=3.7.5"
|
||||||
|
23
pubspec.yaml
23
pubspec.yaml
@ -1,11 +1,11 @@
|
|||||||
name: hacki
|
name: hacki
|
||||||
description: A Hacker News reader.
|
description: A Hacker News reader.
|
||||||
version: 1.0.12+90
|
version: 1.1.1+92
|
||||||
publish_to: none
|
publish_to: none
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=2.17.0 <3.0.0"
|
sdk: ">=2.17.0 <3.0.0"
|
||||||
flutter: "3.7.3"
|
flutter: "3.7.5"
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
adaptive_theme: ^3.0.0
|
adaptive_theme: ^3.0.0
|
||||||
@ -30,7 +30,6 @@ dependencies:
|
|||||||
flutter_fadein: ^2.0.0
|
flutter_fadein: ^2.0.0
|
||||||
flutter_feather_icons: 2.0.0+1
|
flutter_feather_icons: 2.0.0+1
|
||||||
flutter_inappwebview: ^5.7.2+3
|
flutter_inappwebview: ^5.7.2+3
|
||||||
flutter_linkify: ^5.0.2
|
|
||||||
flutter_local_notifications: ^13.0.0
|
flutter_local_notifications: ^13.0.0
|
||||||
flutter_secure_storage: ^8.0.0
|
flutter_secure_storage: ^8.0.0
|
||||||
flutter_siri_suggestions: ^2.1.0
|
flutter_siri_suggestions: ^2.1.0
|
||||||
@ -44,6 +43,7 @@ dependencies:
|
|||||||
http: ^0.13.5
|
http: ^0.13.5
|
||||||
hydrated_bloc: ^9.1.0
|
hydrated_bloc: ^9.1.0
|
||||||
intl: ^0.18.0
|
intl: ^0.18.0
|
||||||
|
linkify: ^4.1.0
|
||||||
logger: ^1.1.0
|
logger: ^1.1.0
|
||||||
package_info_plus: ^3.0.3
|
package_info_plus: ^3.0.3
|
||||||
path: ^1.8.2
|
path: ^1.8.2
|
||||||
@ -90,4 +90,21 @@ flutter:
|
|||||||
assets:
|
assets:
|
||||||
- assets/images/
|
- 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
|
||||||
|
|
||||||
|
|
||||||
|
Submodule submodules/flutter updated: 9944297138...c07f788888
Reference in New Issue
Block a user