Compare commits

...

16 Commits

72 changed files with 1871 additions and 887 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

@ -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

@ -18,6 +18,8 @@ import flutter_local_notifications
center.delegate = self
WorkmanagerPlugin.register(with: self.registrar(forPlugin: "be.tramckrijte.workmanager.WorkmanagerPlugin")!)
WorkmanagerPlugin.registerTask(withIdentifier: "workmanager.background.task")
if #available(iOS 10.0, *) {
UNUserNotificationCenter.current().delegate = self as? UNUserNotificationCenterDelegate

View File

@ -47,13 +47,14 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
state.copyWith(
isLoggedIn: true,
user: user,
status: AuthStatus.loaded,
),
);
} else {
emit(
state.copyWith(
status: AuthStatus.loaded,
isLoggedIn: false,
status: AuthStatus.loaded,
),
);
}

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';
@ -57,7 +59,15 @@ abstract class Constants {
'(ㆆ_ㆆ)',
].pickRandomly()!;
static final String magicWord = <String>[
'to be over the rainbow!',
'to infinity and beyond!',
'to see the future.',
].pickRandomly()!;
static final String errorMessage = 'Something went wrong...$sadFace';
static final String loginErrorMessage =
'''Failed to log in $sadFace, this could happen if your account requires a CAPTCHA, please try logging in inside a browser to see if this is the case, if so, you may try logging in here again later after CAPTCHA is no longer needed.''';
}
abstract class RegExpConstants {

View File

@ -3,15 +3,18 @@ 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';
import 'package:hacki/main.dart';
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:linkify/linkify.dart';
import 'package:logger/logger.dart';
import 'package:rxdart/rxdart.dart';
part 'comments_state.dart';
@ -73,12 +76,12 @@ class CommentsCubit extends Cubit<CommentsState> {
Future<void> init({
bool onlyShowTargetComment = false,
bool useCommentCache = false,
List<Comment>? targetParents,
List<Comment>? targetAncestors,
}) async {
if (onlyShowTargetComment && (targetParents?.isNotEmpty ?? false)) {
if (onlyShowTargetComment && (targetAncestors?.isNotEmpty ?? false)) {
emit(
state.copyWith(
comments: targetParents,
comments: targetAncestors,
onlyShowTargetComment: true,
status: CommentsStatus.allLoaded,
),
@ -86,9 +89,11 @@ class CommentsCubit extends Cubit<CommentsState> {
_streamSubscription = _storiesRepository
.fetchAllCommentsRecursivelyStream(
ids: targetParents!.last.kids,
level: targetParents.last.level + 1,
ids: targetAncestors!.last.kids,
level: targetAncestors.last.level + 1,
)
.asyncMap(_toBuildableComment)
.whereNotNull()
.listen(_onCommentFetched)
..onDone(_onDone);
@ -106,38 +111,38 @@ class CommentsCubit extends Cubit<CommentsState> {
final Item item = state.item;
final Item updatedItem = state.offlineReading
? item
: await _storiesRepository.fetchItem(id: item.id) ?? item;
: await _storiesRepository.fetchItem(id: item.id).then(_toBuildable) ??
item;
final List<int> kids = sortKids(updatedItem.kids);
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(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
)
.listen(_onCommentFetched)
..onDone(_onDone);
commentStream = _storiesRepository.fetchCommentsStream(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
);
break;
case FetchMode.eager:
_streamSubscription = _storiesRepository
.fetchAllCommentsRecursivelyStream(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
)
.listen(_onCommentFetched)
..onDone(_onDone);
commentStream = _storiesRepository.fetchAllCommentsRecursivelyStream(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
);
break;
}
}
_streamSubscription = commentStream
.asyncMap(_toBuildableComment)
.whereNotNull()
.listen(_onCommentFetched)
..onDone(_onDone);
}
Future<void> refresh() async {
@ -176,22 +181,23 @@ 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(
ids: kids,
)
.listen(_onCommentFetched)
..onDone(_onDone);
commentStream = _storiesRepository.fetchCommentsStream(
ids: kids,
);
} else {
_streamSubscription = _storiesRepository
.fetchAllCommentsRecursivelyStream(
ids: kids,
)
.listen(_onCommentFetched)
..onDone(_onDone);
commentStream = _storiesRepository.fetchAllCommentsRecursivelyStream(
ids: kids,
);
}
_streamSubscription = commentStream
.asyncMap(_toBuildableComment)
.whereNotNull()
.listen(_onCommentFetched)
..onDone(_onDone);
emit(
state.copyWith(
item: updatedItem,
@ -227,23 +233,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),
cmt.copyWith(level: level),
),
),
);
@ -273,8 +274,9 @@ class CommentsCubit extends Cubit<CommentsState> {
Future<void> loadParentThread() async {
unawaited(HapticFeedback.lightImpact());
emit(state.copyWith(fetchParentStatus: CommentsStatus.loading));
final Story? parent =
await _storiesRepository.fetchParentStory(id: state.item.id);
final Story? parent = await _storiesRepository
.fetchParentStory(id: state.item.id)
.then(_toBuildableStory);
if (parent == null) {
return;
@ -340,22 +342,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 +382,51 @@ 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<Item?> _toBuildable(Item? item) async {
if (item == null) return null;
if (text.isEmpty) {
return <LinkifyElement>[];
switch (item.runtimeType) {
case Comment:
return _toBuildableComment(item as Comment);
case Story:
return _toBuildableStory(item as Story);
}
if (linkifiers.isEmpty) {
return list;
return null;
}
static Future<BuildableComment?> _toBuildableComment(Comment? comment) async {
if (comment == null) return null;
final List<LinkifyElement> elements =
await compute<String, List<LinkifyElement>>(
LinkifierUtil.linkify,
comment.text,
);
final BuildableComment buildableComment =
BuildableComment.fromComment(comment, elements: elements);
return buildableComment;
}
static Future<BuildableStory?> _toBuildableStory(Story? story) async {
if (story == null) {
return null;
} else if (story.text.isEmpty) {
return BuildableStory.fromTitleOnlyStory(story);
}
for (final Linkifier linkifier in linkifiers) {
list = linkifier.parse(list, options);
}
final List<LinkifyElement> elements =
await compute<String, List<LinkifyElement>>(
LinkifierUtil.linkify,
story.text,
);
return list;
final BuildableStory buildableStory =
BuildableStory.fromStory(story, elements: elements);
return buildableStory;
}
@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

@ -15,19 +15,19 @@ class SearchCubit extends Cubit<SearchState> {
final SearchRepository _searchRepository;
StreamSubscription<Story>? streamSubscription;
StreamSubscription<Item>? streamSubscription;
void search(String query) {
streamSubscription?.cancel();
emit(
state.copyWith(
results: <Story>[],
results: <Item>[],
status: SearchStatus.loading,
params: state.params.copyWith(query: query, page: 0),
),
);
streamSubscription =
_searchRepository.search(params: state.params).listen(_onStoryFetched)
_searchRepository.search(params: state.params).listen(_onItemFetched)
..onDone(() {
emit(state.copyWith(status: SearchStatus.loaded));
});
@ -43,7 +43,7 @@ class SearchCubit extends Cubit<SearchState> {
),
);
streamSubscription =
_searchRepository.search(params: state.params).listen(_onStoryFetched)
_searchRepository.search(params: state.params).listen(_onItemFetched)
..onDone(() {
emit(state.copyWith(status: SearchStatus.loaded));
});
@ -69,6 +69,8 @@ class SearchCubit extends Cubit<SearchState> {
}
void removeFilter<T extends SearchFilter>() {
if (state.params.contains<T>() == false) return;
emit(
state.copyWith(
params: state.params.copyWithFilterRemoved<T>(),
@ -78,6 +80,16 @@ class SearchCubit extends Cubit<SearchState> {
search(state.params.query);
}
void onToggled(TypeTagFilter filter) {
if (state.params.contains<TypeTagFilter>() &&
state.params.get<TypeTagFilter>() == filter) {
removeFilter<TypeTagFilter>();
} else {
removeFilter<TypeTagFilter>();
addFilter<TypeTagFilter>(filter);
}
}
void onSortToggled() {
emit(
state.copyWith(
@ -90,10 +102,44 @@ class SearchCubit extends Cubit<SearchState> {
search(state.params.query);
}
void _onStoryFetched(Story story) {
void onDateTimeRangeUpdated(DateTime start, DateTime end) {
final DateTime updatedStart = start.copyWith(
second: 0,
millisecond: 0,
microsecond: 0,
);
final DateTime updatedEnd = end.copyWith(
second: 0,
millisecond: 0,
microsecond: 0,
);
final DateTime? existingStart =
state.params.get<DateTimeRangeFilter>()?.startTime;
final DateTime? existingEnd =
state.params.get<DateTimeRangeFilter>()?.endTime;
if (existingStart == updatedStart && existingEnd == updatedEnd) return;
addFilter(
DateTimeRangeFilter(
startTime: updatedStart,
endTime: updatedEnd,
),
);
}
void onPostedByChanged(String? username) {
if (username == null) {
removeFilter<PostedByFilter>();
} else {
addFilter(PostedByFilter(author: username));
}
}
void _onItemFetched(Item item) {
emit(
state.copyWith(
results: List<Story>.from(state.results)..add(story),
results: List<Item>.from(state.results)..add(item),
),
);
}

View File

@ -16,15 +16,15 @@ class SearchState extends Equatable {
SearchState.init()
: status = SearchStatus.initial,
results = <Story>[],
results = <Item>[],
params = SearchParams.init();
final List<Story> results;
final List<Item> results;
final SearchStatus status;
final SearchParams params;
SearchState copyWith({
List<Story>? results,
List<Item>? results,
SearchStatus? status,
SearchParams? params,
}) {

View File

@ -20,20 +20,20 @@ class TimeMachineCubit extends Cubit<TimeMachineState> {
final CommentCache _commentCache;
Future<void> activateTimeMachine(Comment comment) async {
emit(state.copyWith(parents: <Comment>[]));
emit(state.copyWith(ancestors: <Comment>[]));
final List<Comment> parents = <Comment>[];
final List<Comment> ancestors = <Comment>[];
Comment? parent = _commentCache.getComment(comment.parent);
parent ??= await _sembastRepository.getCachedComment(id: comment.parent);
while (parent != null) {
parents.insert(0, parent);
ancestors.insert(0, parent);
final int parentId = parent.parent;
parent = _commentCache.getComment(parentId);
parent ??= await _sembastRepository.getCachedComment(id: parentId);
}
emit(state.copyWith(parents: parents));
emit(state.copyWith(ancestors: ancestors));
}
}

View File

@ -1,18 +1,18 @@
part of 'time_machine_cubit.dart';
class TimeMachineState extends Equatable {
const TimeMachineState({required this.parents});
const TimeMachineState({required this.ancestors});
TimeMachineState.init() : parents = <Comment>[];
TimeMachineState.init() : ancestors = <Comment>[];
final List<Comment> parents;
final List<Comment> ancestors;
TimeMachineState copyWith({
List<Comment>? parents,
List<Comment>? ancestors,
}) {
return TimeMachineState(parents: parents ?? this.parents);
return TimeMachineState(ancestors: ancestors ?? this.ancestors);
}
@override
List<Object?> get props => <Object?>[parents];
List<Object?> get props => <Object?>[ancestors];
}

View File

@ -2,17 +2,14 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/auth/auth_bloc.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/main.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/item/models/models.dart';
import 'package:hacki/screens/item/widgets/widgets.dart';
import 'package:hacki/screens/screens.dart' show ItemScreen, ItemScreenArgs;
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:share_plus/share_plus.dart';
extension StateExtension on State {
@ -59,11 +56,11 @@ extension StateExtension on State {
context.read<BlocklistCubit>().state.blocklist.contains(item.by);
showModalBottomSheet<MenuAction>(
context: context,
isScrollControlled: true,
builder: (BuildContext context) {
return MorePopupMenu(
item: item,
isBlocked: isBlocked,
onStoryLinkTapped: onStoryLinkTapped,
onLoginTapped: onLoginTapped,
);
},
@ -74,6 +71,9 @@ extension StateExtension on State {
break;
case MenuAction.downvote:
break;
case MenuAction.fav:
onFavTapped(item);
break;
case MenuAction.share:
onShareTapped(item, rect);
break;
@ -90,24 +90,13 @@ extension StateExtension on State {
});
}
Future<void> onStoryLinkTapped(String link) async {
final int? id = link.itemId;
if (id != null) {
await locator
.get<StoriesRepository>()
.fetchItem(id: id)
.then((Item? item) {
if (mounted) {
if (item != null) {
HackiApp.navigatorKey.currentState!.pushNamed(
ItemScreen.routeName,
arguments: ItemScreenArgs(item: item),
);
}
}
});
void onFavTapped(Item item) {
final FavCubit favCubit = context.read<FavCubit>();
final bool isFav = favCubit.state.favIds.contains(item.id);
if (isFav) {
favCubit.removeFav(item.id);
} else {
LinkUtil.launch(link);
favCubit.addFav(item.id);
}
}
@ -231,17 +220,11 @@ extension StateExtension on State {
}
void onLoginTapped() {
final TextEditingController usernameController = TextEditingController();
final TextEditingController passwordController = TextEditingController();
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return LoginDialog(
usernameController: usernameController,
passwordController: passwordController,
showSnackBar: showSnackBar,
);
return const LoginDialog();
},
);
}

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,62 @@ extension WidgetModifier on Widget {
child: this,
);
}
Widget contextMenuBuilder(
BuildContext context,
EditableTextState editableTextState, {
required Item item,
}) {
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 = item.text.substring(start, end);
if (item is Buildable) {
final Iterable<EmphasisElement> emphasisElements =
(item as Buildable).elements.whereType<EmphasisElement>();
int count = 1;
while (selectedText.contains(' ') && count <= emphasisElements.length) {
final int s = (start + count * 2).clamp(0, item.text.length);
final int e = (end + count * 2).clamp(0, item.text.length);
selectedText = item.text.substring(s, e);
count++;
}
count = 1;
while (selectedText.contains(' ') && count <= emphasisElements.length) {
final int s = (start - count * 2).clamp(0, item.text.length);
final int e = (end - count * 2).clamp(0, item.text.length);
selectedText = item.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

@ -0,0 +1,5 @@
import 'package:hacki/screens/widgets/custom_linkify/custom_linkify.dart';
mixin Buildable {
List<LinkifyElement> get elements;
}

View File

@ -1,10 +1,10 @@
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:hacki/models/comment.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/models/item/buildable.dart';
import 'package:hacki/models/item/comment.dart';
import 'package:linkify/linkify.dart';
/// [BuildableComment] is a subtype of [Comment] which stores
/// the corresponding [LinkifyElement] for faster widget building.
class BuildableComment extends Comment {
class BuildableComment extends Comment with Buildable {
BuildableComment({
required super.id,
required super.time,
@ -33,5 +33,6 @@ class BuildableComment extends Comment {
level: comment.level,
);
@override
final List<LinkifyElement> elements;
}

View File

@ -0,0 +1,46 @@
import 'package:hacki/models/item/buildable.dart';
import 'package:hacki/models/item/story.dart';
import 'package:linkify/linkify.dart';
/// [BuildableStory] is a subtype of [Story] which stores
/// the corresponding [LinkifyElement] for faster widget building.
class BuildableStory extends Story with Buildable {
const BuildableStory({
required super.id,
required super.time,
required super.score,
required super.by,
required super.text,
required super.kids,
required super.descendants,
required super.title,
required super.type,
required super.url,
required super.parts,
required this.elements,
});
BuildableStory.fromStory(Story story, {required this.elements})
: super(
id: story.id,
time: story.time,
score: story.score,
by: story.by,
text: story.text,
kids: story.kids,
descendants: story.descendants,
title: story.title,
type: story.type,
url: story.url,
parts: story.parts,
);
BuildableStory.fromTitleOnlyStory(Story story)
: this.fromStory(
story,
elements: const <LinkifyElement>[],
);
@override
final List<LinkifyElement> elements;
}

View File

@ -1,4 +1,4 @@
import 'package:hacki/models/item.dart';
import 'package:hacki/models/item/item.dart';
class Comment extends Item {
Comment({
@ -41,38 +41,6 @@ class Comment extends Item {
);
}
@override
Map<String, dynamic> toJson() => <String, dynamic>{
'id': id,
'time': time,
'by': by,
'text': text,
'kids': kids,
'parent': parent,
'deleted': deleted,
'dead': dead,
'score': score,
'level': level,
};
@override
bool? get stringify => false;
@override
List<Object?> get props => <Object?>[
id,
score,
descendants,
time,
by,
title,
url,
kids,
dead,
parts,
deleted,
parent,
text,
type,
];
}

View File

@ -1,8 +1,15 @@
import 'package:equatable/equatable.dart';
import 'package:hacki/extensions/date_time_extension.dart';
import 'package:hacki/models/comment.dart';
import 'package:hacki/models/poll_option.dart';
import 'package:hacki/models/story.dart';
import 'package:hacki/models/item/comment.dart';
import 'package:hacki/models/item/poll_option.dart';
import 'package:hacki/models/item/story.dart';
export 'buildable.dart';
export 'buildable_comment.dart';
export 'buildable_story.dart';
export 'comment.dart';
export 'poll_option.dart';
export 'story.dart';
/// [Item] is the base type of [Story], [Comment] and [PollOption].
class Item extends Equatable {
@ -101,6 +108,7 @@ class Item extends Equatable {
'deleted': deleted,
'type': type,
'parts': parts,
'parent': parent,
};
}

View File

@ -1,6 +1,6 @@
import 'dart:convert';
import 'package:hacki/models/item.dart';
import 'package:hacki/models/item/item.dart';
class PollOption extends Item {
const PollOption({
@ -52,19 +52,7 @@ class PollOption extends Item {
@override
Map<String, dynamic> toJson() {
return <String, dynamic>{
'descendants': descendants,
'id': id,
'score': score,
'time': time,
'by': by,
'title': title,
'url': url,
'kids': kids,
'text': text,
'dead': dead,
'deleted': deleted,
'type': type,
'parts': parts,
...super.toJson(),
'ratio': ratio,
};
}
@ -75,22 +63,4 @@ class PollOption extends Item {
const JsonEncoder.withIndent(' ').convert(this);
return 'PollOption $prettyString';
}
@override
List<Object?> get props => <Object?>[
id,
score,
descendants,
time,
by,
title,
url,
kids,
dead,
parts,
deleted,
parent,
text,
type,
];
}

View File

@ -1,5 +1,5 @@
import 'package:hacki/config/constants.dart';
import 'package:hacki/models/item.dart';
import 'package:hacki/models/item/item.dart';
class Story extends Item {
const Story({
@ -54,25 +54,6 @@ class Story extends Item {
return authority;
}
@override
Map<String, dynamic> toJson() {
return <String, dynamic>{
'descendants': descendants,
'id': id,
'score': score,
'time': time,
'by': by,
'title': title,
'url': url,
'kids': kids,
'text': text,
'dead': dead,
'deleted': deleted,
'type': type,
'parts': parts,
};
}
@override
String toString() {
// final String prettyString =
@ -80,23 +61,4 @@ class Story extends Item {
// return 'Story $prettyString';
return 'Story $id';
}
@override
List<Object?> get props => <Object?>[
id,
score,
descendants,
time,
by,
title,
text,
url,
kids,
dead,
parts,
deleted,
parent,
text,
type,
];
}

View File

@ -1,13 +1,10 @@
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';
export 'item/item.dart';
export 'post_data.dart';
export 'preference.dart';
export 'search_params.dart';
export 'story.dart';
export 'story_type.dart';
export 'user.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

@ -8,8 +8,19 @@ abstract class NumericFilter extends SearchFilter {}
abstract class TagFilter extends SearchFilter {}
abstract class TypeTagFilter extends TagFilter {
static List<TypeTagFilter> all = <TypeTagFilter>[
const StoryFilter(),
const PollFilter(),
const CommentFilter(),
const FrontPageFilter(),
const AskHnFilter(),
const ShowHnFilter(),
];
}
class DateTimeRangeFilter implements NumericFilter {
DateTimeRangeFilter({
const DateTimeRangeFilter({
this.startTime,
this.endTime,
});
@ -37,7 +48,7 @@ class DateTimeRangeFilter implements NumericFilter {
}
class PostedByFilter implements TagFilter {
PostedByFilter({required this.author});
const PostedByFilter({required this.author});
final String author;
@ -47,8 +58,8 @@ class PostedByFilter implements TagFilter {
}
}
class FrontPageFilter implements TagFilter {
FrontPageFilter();
class FrontPageFilter implements TypeTagFilter {
const FrontPageFilter();
@override
String get query {
@ -56,8 +67,8 @@ class FrontPageFilter implements TagFilter {
}
}
class ShowHnFilter implements TagFilter {
ShowHnFilter();
class ShowHnFilter implements TypeTagFilter {
const ShowHnFilter();
@override
String get query {
@ -65,8 +76,8 @@ class ShowHnFilter implements TagFilter {
}
}
class AskHnFilter implements TagFilter {
AskHnFilter();
class AskHnFilter implements TypeTagFilter {
const AskHnFilter();
@override
String get query {
@ -74,8 +85,8 @@ class AskHnFilter implements TagFilter {
}
}
class PollFilter implements TagFilter {
PollFilter();
class PollFilter implements TypeTagFilter {
const PollFilter();
@override
String get query {
@ -83,8 +94,8 @@ class PollFilter implements TagFilter {
}
}
class StoryFilter implements TagFilter {
StoryFilter();
class StoryFilter implements TypeTagFilter {
const StoryFilter();
@override
String get query {
@ -92,8 +103,17 @@ class StoryFilter implements TagFilter {
}
}
class CommentFilter implements TypeTagFilter {
const CommentFilter();
@override
String get query {
return 'comment';
}
}
class CombinedFilter implements TagFilter {
CombinedFilter({required this.filters});
const CombinedFilter({required this.filters});
final List<TagFilter> filters;

View File

@ -70,7 +70,6 @@ class SearchParams extends Equatable {
filters.whereType<NumericFilter>();
final List<TagFilter> tagFilters = <TagFilter>[
...filters.whereType<TagFilter>(),
CombinedFilter(filters: <TagFilter>[StoryFilter(), PollFilter()]),
];
if (numericFilters.isNotEmpty) {

View File

@ -13,7 +13,7 @@ class SearchRepository {
final Dio _dio;
Stream<Story> search({
Stream<Item> search({
required SearchParams params,
}) async* {
final String url = '$_baseUrl${params.filteredQuery}';
@ -36,37 +36,53 @@ class SearchRepository {
final int score = hit['points'] as int? ?? 0;
final int descendants = hit['num_comments'] as int? ?? 0;
// Getting rid of comments, only keeping stories for convenience.
// Don't judge me.
if (title.isEmpty) {
continue;
}
final String url = hit['url'] as String? ?? '';
final String type =
title.toLowerCase().contains('poll:') ? 'poll' : 'story';
final String text = hit['story_text'] as String? ?? '';
final String parsedText = await compute<String, String>(
HtmlUtil.parseHtml,
text,
);
final int id = int.parse(hit['objectID'] as String? ?? '0');
final Story story = Story(
descendants: descendants,
id: id,
score: score,
time: createdAt,
by: by,
title: title,
text: parsedText,
url: url,
type: type,
// response doesn't contain kids and parts.
kids: const <int>[],
parts: const <int>[],
);
yield story;
if (title.isEmpty) {
final String text = hit['comment_text'] as String? ?? '';
final String parsedText = await compute<String, String>(
HtmlUtil.parseHtml,
text,
);
final int parentId = hit['parent_id'] as int? ?? 0;
final Comment comment = Comment(
id: id,
score: score,
time: createdAt,
by: by,
text: parsedText,
kids: const <int>[],
parent: parentId,
dead: false,
deleted: false,
level: 0,
);
yield comment;
} else {
final String text = hit['story_text'] as String? ?? '';
final String parsedText = await compute<String, String>(
HtmlUtil.parseHtml,
text,
);
final Story story = Story(
descendants: descendants,
id: id,
score: score,
time: createdAt,
by: by,
title: title,
text: parsedText,
url: url,
type: type,
// response doesn't contain kids and parts.
kids: const <int>[],
parts: const <int>[],
);
yield story;
}
}
return;
}

View File

@ -17,216 +17,29 @@ class StoriesRepository {
final FirebaseClient _firebaseClient;
static const String _baseUrl = 'https://hacker-news.firebaseio.com/v0/';
/// Fetch a [User] by its [id].
/// Hacker News uses user's username as [id].
Future<User> fetchUser({required String id}) async {
final User user = await _firebaseClient
.get('${_baseUrl}user/$id.json')
.then((dynamic val) {
final Map<String, dynamic> json = val as Map<String, dynamic>;
final User user = User.fromJson(json);
return user;
});
return user;
}
/// Fetch ids of stories of a certain [StoryType].
Future<List<int>> fetchStoryIds({required StoryType type}) async {
final List<int> ids = await _firebaseClient
.get('$_baseUrl${type.path}.json')
.then((dynamic val) {
final List<int> ids = (val as List<dynamic>).cast<int>();
return ids;
});
return ids;
}
/// Fetch a [Story] based on its id.
Future<Story?> fetchStory({required int id}) async {
final Story? story = await _firebaseClient
Future<Map<String, dynamic>?> _fetchItemJson(int id) async {
return _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
.then((Map<String, dynamic>? json) {
if (json == null) return null;
final Story story = Story.fromJson(json);
return story;
});
return story;
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?));
}
/// Fetch a list of [Comment] based on ids and return results
/// using a stream.
Stream<Comment> fetchCommentsStream({
required List<int> ids,
int level = 0,
Comment? Function(int)? getFromCache,
}) async* {
for (final int id in ids) {
Comment? comment = getFromCache?.call(id)?.copyWith(level: level);
comment ??= await _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
.then((Map<String, dynamic>? json) async {
if (json == null) return null;
final Comment comment = Comment.fromJson(json, level: level);
return comment;
});
if (comment != null) {
yield comment;
}
}
return;
}
/// Fetch a list of [Comment] based on ids recursively and
/// return results using a stream.
Stream<Comment> fetchAllCommentsRecursivelyStream({
required List<int> ids,
int level = 0,
Comment? Function(int)? getFromCache,
}) async* {
for (final int id in ids) {
Comment? comment = getFromCache?.call(id)?.copyWith(level: level);
comment ??= await _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
.then((Map<String, dynamic>? json) async {
if (json == null) return null;
final Comment comment = Comment.fromJson(json, level: level);
return comment;
});
if (comment != null) {
yield comment;
yield* fetchAllCommentsRecursivelyStream(
ids: comment.kids,
level: level + 1,
getFromCache: getFromCache,
);
}
}
return;
}
/// Fetch a list of [Item] based on ids and return results
/// using a stream.
Stream<Item> fetchItemsStream({required List<int> ids}) async* {
for (final int id in ids) {
final Item? item = await _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
.then((Map<String, dynamic>? json) async {
if (json == null) return null;
final String type = json['type'] as String;
if (type == 'story' || type == 'job') {
final Story story = Story.fromJson(json);
return story;
} else if (json['type'] == 'comment') {
final Comment comment = Comment.fromJson(json);
return comment;
}
return null;
});
if (item != null) {
yield item;
}
}
}
/// Fetch a list of [Story] based on ids and return results
/// using a stream.
Stream<Story> fetchStoriesStream({required List<int> ids}) async* {
for (final int id in ids) {
final Story? story = await _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
.then((Map<String, dynamic>? json) async {
if (json == null) return null;
final Story story = Story.fromJson(json);
return story;
});
if (story != null) {
yield story;
}
}
}
/// Fetch a list of [PollOption] based on ids and return results
/// using a stream.
Stream<PollOption> fetchPollOptionsStream({required List<int> ids}) async* {
for (final int id in ids) {
final PollOption? option = await _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic json) async {
if (json == null) return null;
final PollOption option =
PollOption.fromJson(json as Map<String, dynamic>);
return option;
});
if (option != null) {
yield option;
}
}
}
/// Fetch a [Comment] based on its id.
Future<Comment?> fetchComment({required int id}) async {
final Comment? comment = await _firebaseClient
Future<Map<String, dynamic>?> _fetchRawItemJson(int id) async {
return _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
.then((Map<String, dynamic>? json) async {
if (json == null) return null;
final Comment comment = Comment.fromJson(json);
return comment;
});
return comment;
}
/// Fetch a raw [Comment] based on its id.
/// The content of [Comment] will not be parsed, use this function only if
/// the format of content doesn't matter, otherwise, use [fetchComment].
Future<Comment?> fetchRawComment({required int id}) async {
final Comment? comment = await _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic val) async {
if (val == null) return null;
final Map<String, dynamic> json = val as Map<String, dynamic>;
final Comment comment = Comment.fromJson(json);
return comment;
});
return comment;
.then((dynamic value) => value as Map<String, dynamic>?);
}
/// Fetch a [Item] based on its id.
Future<Item?> fetchItem({required int id}) async {
final Item? item = await _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic json) => _parseJson(json as Map<String, dynamic>?))
.then((Map<String, dynamic>? json) {
final Item? item =
await _fetchItemJson(id).then((Map<String, dynamic>? json) {
if (json == null) return null;
final String type = json['type'] as String;
if (type == 'story' || type == 'job' || type == 'poll') {
final Story story = Story.fromJson(json);
return story;
} else if (json['type'] == 'comment') {
} else if (type == 'comment') {
final Comment comment = Comment.fromJson(json);
return comment;
}
@ -240,9 +53,7 @@ class StoriesRepository {
/// The content of [Item] will not be parsed, use this function only if
/// the format of content doesn't matter, otherwise, use [fetchItem].
Future<Item?> fetchRawItem({required int id}) async {
final Item? item = await _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic val) {
final Item? item = await _fetchRawItemJson(id).then((dynamic val) {
if (val == null) return null;
final Map<String, dynamic> json = val as Map<String, dynamic>;
@ -251,7 +62,7 @@ class StoriesRepository {
if (type == 'story' || type == 'job' || type == 'poll') {
final Story story = Story.fromJson(json);
return story;
} else if (json['type'] == 'comment') {
} else if (type == 'comment') {
final Comment comment = Comment.fromJson(json);
return comment;
}
@ -261,6 +72,20 @@ class StoriesRepository {
return item;
}
/// Fetch a [User] by its [id].
/// Hacker News uses user's username as [id].
Future<User> fetchUser({required String id}) async {
final User user = await _firebaseClient
.get('${_baseUrl}user/$id.json')
.then((dynamic val) {
final Map<String, dynamic> json = val as Map<String, dynamic>;
final User user = User.fromJson(json);
return user;
});
return user;
}
/// Fetch a list of ids of [Story] or [Comment] submitted by the user.
Future<List<int>?> fetchSubmitted({required String userId}) async {
final List<int>? submitted = await _firebaseClient
@ -278,6 +103,59 @@ class StoriesRepository {
return submitted;
}
/// Fetch ids of stories of a certain [StoryType].
Future<List<int>> fetchStoryIds({required StoryType type}) async {
final List<int> ids = await _firebaseClient
.get('$_baseUrl${type.path}.json')
.then((dynamic val) {
final List<int> ids = (val as List<dynamic>).cast<int>();
return ids;
});
return ids;
}
/// Fetch a [Story] based on its id.
Future<Story?> fetchStory({required int id}) async {
final Story? story =
await _fetchItemJson(id).then((Map<String, dynamic>? json) {
if (json == null) return null;
final Story story = Story.fromJson(json);
return story;
});
return story;
}
/// Fetch a [Comment] based on its id.
Future<Comment?> fetchComment({required int id}) async {
final Comment? comment =
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
if (json == null) return null;
final Comment comment = Comment.fromJson(json);
return comment;
});
return comment;
}
/// Fetch a raw [Comment] based on its id.
/// The content of [Comment] will not be parsed, use this function only if
/// the format of content doesn't matter, otherwise, use [fetchComment].
Future<Comment?> fetchRawComment({required int id}) async {
final Comment? comment =
await _fetchRawItemJson(id).then((dynamic val) async {
if (val == null) return null;
final Map<String, dynamic> json = val as Map<String, dynamic>;
final Comment comment = Comment.fromJson(json);
return comment;
});
return comment;
}
/// Fetch the parent [Story] of a [Comment].
Future<Story?> fetchParentStory({required int id}) async {
Item? item;
@ -331,6 +209,122 @@ class StoriesRepository {
);
}
/// Fetch a list of [Comment] based on ids and return results
/// using a stream.
Stream<Comment> fetchCommentsStream({
required List<int> ids,
int level = 0,
Comment? Function(int)? getFromCache,
}) async* {
for (final int id in ids) {
Comment? comment = getFromCache?.call(id)?.copyWith(level: level);
comment ??=
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
if (json == null) return null;
final Comment comment = Comment.fromJson(json, level: level);
return comment;
});
if (comment != null) {
yield comment;
}
}
return;
}
/// Fetch a list of [Comment] based on ids recursively and
/// return results using a stream.
Stream<Comment> fetchAllCommentsRecursivelyStream({
required List<int> ids,
int level = 0,
Comment? Function(int)? getFromCache,
}) async* {
for (final int id in ids) {
Comment? comment = getFromCache?.call(id)?.copyWith(level: level);
comment ??=
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
if (json == null) return null;
final Comment comment = Comment.fromJson(json, level: level);
return comment;
});
if (comment != null) {
yield comment;
yield* fetchAllCommentsRecursivelyStream(
ids: comment.kids,
level: level + 1,
getFromCache: getFromCache,
);
}
}
return;
}
/// Fetch a list of [Item] based on ids and return results
/// using a stream.
Stream<Item> fetchItemsStream({required List<int> ids}) async* {
for (final int id in ids) {
final Item? item =
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
if (json == null) return null;
final String type = json['type'] as String;
if (type == 'story' || type == 'job') {
final Story story = Story.fromJson(json);
return story;
} else if (type == 'comment') {
final Comment comment = Comment.fromJson(json);
return comment;
}
return null;
});
if (item != null) {
yield item;
}
}
}
/// Fetch a list of [Story] based on ids and return results
/// using a stream.
Stream<Story> fetchStoriesStream({required List<int> ids}) async* {
for (final int id in ids) {
final Story? story =
await _fetchItemJson(id).then((Map<String, dynamic>? json) async {
if (json == null) return null;
final Story story = Story.fromJson(json);
return story;
});
if (story != null) {
yield story;
}
}
}
/// Fetch a list of [PollOption] based on ids and return results
/// using a stream.
Stream<PollOption> fetchPollOptionsStream({required List<int> ids}) async* {
for (final int id in ids) {
final PollOption? option =
await _fetchRawItemJson(id).then((dynamic json) async {
if (json == null) return null;
final PollOption option =
PollOption.fromJson(json as Map<String, dynamic>);
return option;
});
if (option != null) {
yield option;
}
}
}
/// Fetch a list of [Comment] based on ids recursively.
Stream<Comment?> fetchAllChildrenComments({required List<int> ids}) async* {
for (final int id in ids) {
@ -343,7 +337,9 @@ class StoriesRepository {
}
/// Parse the json of an [Item] by removing useless HTML tags.
Future<Map<String, dynamic>?> _parseJson(Map<String, dynamic>? json) async {
static Future<Map<String, dynamic>?> _parseJson(
Map<String, dynamic>? json,
) async {
if (json == null) return null;
final String text = json['text'] as String? ?? '';
final String parsedText = await compute<String, String>(

View File

@ -92,14 +92,12 @@ class _HomeScreenState extends State<HomeScreen>
SchedulerBinding.instance
..addPostFrameCallback((_) {
if (!isTesting) {
FeatureDiscovery.discoverFeatures(
context,
const <String>{
Constants.featureLogIn,
},
);
}
FeatureDiscovery.discoverFeatures(
context,
<String>{
Constants.featureLogIn,
},
);
})
..addPostFrameCallback((_) {
final ModalRoute<dynamic>? route = ModalRoute.of(context);

View File

@ -9,9 +9,8 @@ import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/main.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/stories_repository.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/item/widgets/widgets.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart';
@ -75,7 +74,7 @@ class ItemScreen extends StatefulWidget {
context.read<PreferenceCubit>().state.order,
)..init(
onlyShowTargetComment: args.onlyShowTargetComment,
targetParents: args.targetComments,
targetAncestors: args.targetComments,
useCommentCache: args.useCommentCache,
),
),
@ -117,7 +116,7 @@ class ItemScreen extends StatefulWidget {
context.read<PreferenceCubit>().state.order,
)..init(
onlyShowTargetComment: args.onlyShowTargetComment,
targetParents: args.targetComments,
targetAncestors: args.targetComments,
),
),
],
@ -174,16 +173,14 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
SchedulerBinding.instance
..addPostFrameCallback((_) {
if (!isTesting) {
FeatureDiscovery.discoverFeatures(
context,
const <String>{
Constants.featurePinToTop,
Constants.featureAddStoryToFavList,
Constants.featureOpenStoryInWebView,
},
);
}
FeatureDiscovery.discoverFeatures(
context,
<String>{
Constants.featurePinToTop,
Constants.featureAddStoryToFavList,
Constants.featureOpenStoryInWebView,
},
);
})
..addPostFrameCallback((_) {
final ModalRoute<dynamic>? route = ModalRoute.of(context);
@ -292,8 +289,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
topPadding: topPadding,
splitViewEnabled: widget.splitViewEnabled,
onMoreTapped: onMoreTapped,
onStoryLinkTapped: onStoryLinkTapped,
onLoginTapped: onLoginTapped,
onRightMoreTapped: onRightMoreTapped,
),
),
@ -317,8 +312,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
.withOpacity(0.6),
item: widget.item,
scrollController: scrollController,
onBackgroundTap: onFeatureDiscoveryDismissed,
onDismiss: onFeatureDiscoveryDismissed,
splitViewEnabled: state.enabled,
expanded: state.expanded,
onZoomTap:
@ -358,8 +351,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
Theme.of(context).canvasColor.withOpacity(0.6),
item: widget.item,
scrollController: scrollController,
onBackgroundTap: onFeatureDiscoveryDismissed,
onDismiss: onFeatureDiscoveryDismissed,
onFontSizeTap: onFontSizeTapped,
fontSizeIconButtonKey: fontSizeIconButtonKey,
),
@ -372,8 +363,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
topPadding: topPadding,
splitViewEnabled: widget.splitViewEnabled,
onMoreTapped: onMoreTapped,
onStoryLinkTapped: onStoryLinkTapped,
onLoginTapped: onLoginTapped,
onRightMoreTapped: onRightMoreTapped,
),
bottomSheet: ReplyBox(
@ -395,15 +384,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
);
}
Future<bool> onFeatureDiscoveryDismissed() {
featureDiscoveryDismissThrottle.run(() {
HapticFeedback.lightImpact();
ScaffoldMessenger.of(context).clearSnackBars();
showSnackBar(content: 'Tap on icon to continue');
});
return Future<bool>.value(false);
}
void onFontSizeTapped() {
const Offset offset = Offset.zero;
final RenderBox overlay =
@ -469,7 +449,7 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
children: <Widget>[
ListTile(
leading: const Icon(Icons.av_timer),
title: const Text('View parents'),
title: const Text('View ancestors'),
onTap: () {
Navigator.pop(context);
onTimeMachineActivated(comment);
@ -513,7 +493,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
size: size,
deviceType: deviceType,
widthFactor: widthFactor,
onStoryLinkTapped: onStoryLinkTapped,
);
},
);

View File

@ -1,6 +1,7 @@
enum MenuAction {
upvote,
downvote,
fav,
share,
block,
flag,

View File

@ -11,8 +11,6 @@ class CustomAppBar extends AppBar {
required ScrollController scrollController,
required Item item,
required Color super.backgroundColor,
required Future<bool> Function() onBackgroundTap,
required Future<bool> Function() onDismiss,
required VoidCallback onFontSizeTap,
required GlobalKey fontSizeIconButtonKey,
bool splitViewEnabled = false,
@ -41,26 +39,26 @@ class CustomAppBar extends AppBar {
),
IconButton(
key: fontSizeIconButtonKey,
icon: const Icon(
Icons.format_size,
icon: Text(
String.fromCharCode(FeatherIcons.type.codePoint),
style: TextStyle(
fontWeight: FontWeight.w800,
fontSize: TextDimens.pt18,
fontFamily: FeatherIcons.type.fontFamily,
package: FeatherIcons.type.fontPackage,
),
),
onPressed: onFontSizeTap,
),
if (item is Story)
PinIconButton(
story: item,
onBackgroundTap: onBackgroundTap,
onDismiss: onDismiss,
),
FavIconButton(
storyId: item.id,
onBackgroundTap: onBackgroundTap,
onDismiss: onDismiss,
),
LinkIconButton(
storyId: item.id,
onBackgroundTap: onBackgroundTap,
onDismiss: onDismiss,
),
],
);

View File

@ -1,24 +1,18 @@
import 'dart:async';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
class FavIconButton extends StatelessWidget {
const FavIconButton({
super.key,
required this.storyId,
required this.onBackgroundTap,
required this.onDismiss,
});
final int storyId;
final Future<bool> Function() onBackgroundTap;
final Future<bool> Function() onDismiss;
@override
Widget build(BuildContext context) {
@ -27,15 +21,7 @@ class FavIconButton extends StatelessWidget {
final bool isFav = favState.favIds.contains(storyId);
return IconButton(
tooltip: 'Add to favorites',
icon: DescribedFeatureOverlay(
onBackgroundTap: onBackgroundTap,
onDismiss: onDismiss,
onComplete: () async {
unawaited(HapticFeedback.lightImpact());
return true;
},
overflowMode: OverflowMode.extendBackground,
targetColor: Theme.of(context).primaryColor,
icon: CustomDescribedFeatureOverlay(
tapTarget: Icon(
isFav ? Icons.favorite : Icons.favorite_border,
color: Palette.white,

View File

@ -1,9 +1,6 @@
import 'dart:async';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
@ -11,40 +8,28 @@ class LinkIconButton extends StatelessWidget {
const LinkIconButton({
super.key,
required this.storyId,
required this.onBackgroundTap,
required this.onDismiss,
});
final int storyId;
final Future<bool> Function() onBackgroundTap;
final Future<bool> Function() onDismiss;
@override
Widget build(BuildContext context) {
return IconButton(
tooltip: 'Open this story in browser',
icon: DescribedFeatureOverlay(
onBackgroundTap: onBackgroundTap,
onDismiss: onDismiss,
onComplete: () async {
unawaited(HapticFeedback.lightImpact());
return true;
},
overflowMode: OverflowMode.extendBackground,
targetColor: Theme.of(context).primaryColor,
tapTarget: const Icon(
icon: const CustomDescribedFeatureOverlay(
tapTarget: Icon(
Icons.stream,
color: Palette.white,
),
featureId: Constants.featureOpenStoryInWebView,
title: const Text('Open in Browser'),
description: const Text(
title: Text('Open in Browser'),
description: Text(
'Want more than just reading and replying? '
'You can tap here to open this story in a '
'browser.',
style: TextStyle(fontSize: TextDimens.pt16),
),
child: const Icon(
child: Icon(
Icons.stream,
),
),

View File

@ -2,25 +2,21 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
class LoginDialog extends StatelessWidget {
const LoginDialog({
super.key,
required this.usernameController,
required this.passwordController,
required this.showSnackBar,
});
class LoginDialog extends StatefulWidget {
const LoginDialog({super.key});
final TextEditingController usernameController;
final TextEditingController passwordController;
final void Function({
required String content,
VoidCallback? action,
String? label,
}) showSnackBar;
@override
State<LoginDialog> createState() => _LoginDialogState();
}
class _LoginDialogState extends State<LoginDialog> {
final TextEditingController usernameController = TextEditingController();
final TextEditingController passwordController = TextEditingController();
@override
Widget build(BuildContext context) {
@ -90,9 +86,10 @@ class LoginDialog extends StatelessWidget {
Padding(
padding: const EdgeInsets.only(
left: Dimens.pt18,
right: Dimens.pt6,
),
child: Text(
Constants.errorMessage,
Constants.loginErrorMessage,
style: const TextStyle(
color: Palette.grey,
fontSize: TextDimens.pt12,

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';
@ -26,8 +25,6 @@ class MainView extends StatelessWidget {
required this.topPadding,
required this.splitViewEnabled,
required this.onMoreTapped,
required this.onStoryLinkTapped,
required this.onLoginTapped,
required this.onRightMoreTapped,
});
@ -39,8 +36,6 @@ class MainView extends StatelessWidget {
final double topPadding;
final bool splitViewEnabled;
final void Function(Item item, Rect? rect) onMoreTapped;
final ValueChanged<String> onStoryLinkTapped;
final VoidCallback onLoginTapped;
final ValueChanged<Comment> onRightMoreTapped;
static const int _loadingIndicatorOpacityAnimationDuration = 300;
@ -124,8 +119,6 @@ class MainView extends StatelessWidget {
topPadding: topPadding,
splitViewEnabled: splitViewEnabled,
onMoreTapped: onMoreTapped,
onStoryLinkTapped: onStoryLinkTapped,
onLoginTapped: onLoginTapped,
onRightMoreTapped: onRightMoreTapped,
);
} else if (index == state.comments.length + 1) {
@ -150,8 +143,6 @@ class MainView extends StatelessWidget {
child: CommentTile(
comment: comment,
level: comment.level,
myUsername:
authState.isLoggedIn ? authState.username : null,
opUsername: state.item.by,
fetchMode: state.fetchMode,
onReplyTapped: (Comment cmt) {
@ -178,7 +169,6 @@ class MainView extends StatelessWidget {
focusNode.requestFocus();
},
onMoreTapped: onMoreTapped,
onStoryLinkTapped: onStoryLinkTapped,
onRightMoreTapped: onRightMoreTapped,
),
);
@ -225,8 +215,6 @@ class _ParentItemSection extends StatelessWidget {
required this.topPadding,
required this.splitViewEnabled,
required this.onMoreTapped,
required this.onStoryLinkTapped,
required this.onLoginTapped,
required this.onRightMoreTapped,
});
@ -239,8 +227,6 @@ class _ParentItemSection extends StatelessWidget {
final double topPadding;
final bool splitViewEnabled;
final void Function(Item item, Rect? rect) onMoreTapped;
final ValueChanged<String> onStoryLinkTapped;
final VoidCallback onLoginTapped;
final ValueChanged<Comment> onRightMoreTapped;
@override
@ -340,12 +326,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 +360,10 @@ class _ParentItemSection extends StatelessWidget {
),
],
),
textAlign: TextAlign.center,
textScaleFactor: MediaQuery.of(
context,
).textScaleFactor,
),
),
)
@ -392,32 +378,8 @@ class _ParentItemSection extends StatelessWidget {
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt10,
),
child: SelectableLinkify(
text: state.item.text,
textScaleFactor:
MediaQuery.of(context).textScaleFactor,
style: TextStyle(
fontSize: context
.read<PreferenceCubit>()
.state
.fontSize
.fontSize,
),
linkStyle: TextStyle(
fontSize: context
.read<PreferenceCubit>()
.state
.fontSize
.fontSize,
color: Palette.orange,
),
onOpen: (LinkableElement link) {
if (link.url.isStoryLink) {
onStoryLinkTapped(link.url);
} else {
LinkUtil.launch(link.url);
}
},
child: ItemText(
item: state.item,
),
),
),
@ -429,9 +391,7 @@ class _ParentItemSection extends StatelessWidget {
BlocProvider<PollCubit>(
create: (BuildContext context) =>
PollCubit(story: state.item as Story)..init(),
child: PollView(
onLoginTapped: onLoginTapped,
),
child: const PollView(),
),
],
),

View File

@ -1,12 +1,15 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.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/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/item/models/models.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
@ -15,15 +18,24 @@ class MorePopupMenu extends StatelessWidget {
super.key,
required this.item,
required this.isBlocked,
required this.onStoryLinkTapped,
required this.onLoginTapped,
});
final Item item;
final bool isBlocked;
final ValueChanged<String> onStoryLinkTapped;
final VoidCallback onLoginTapped;
static double? _cachedStoryHeight;
static double? _cachedCommentHeight;
static double get storyHeight {
return _cachedStoryHeight ??= Platform.isIOS ? 500 : 530;
}
static double get commentHeight {
return _cachedCommentHeight ??= Platform.isIOS ? 480 : 520;
}
@override
Widget build(BuildContext context) {
return BlocProvider<VoteCubit>(
@ -68,7 +80,7 @@ class MorePopupMenu extends StatelessWidget {
final bool upvoted = voteState.vote == Vote.up;
final bool downvoted = voteState.vote == Vote.down;
return Container(
height: item is Comment ? 430 : 450,
height: item is Comment ? commentHeight : storyHeight,
color: Theme.of(context).canvasColor,
child: Material(
color: Palette.transparent,
@ -88,6 +100,7 @@ class MorePopupMenu extends StatelessWidget {
state.user.description,
),
onTap: () {
Navigator.pop(context);
showDialog<void>(
context: context,
builder: (BuildContext context) => AlertDialog(
@ -112,15 +125,19 @@ class MorePopupMenu extends StatelessWidget {
linkStyle: const TextStyle(
color: Palette.orange,
),
onOpen: (LinkableElement link) {
if (link.url.isStoryLink) {
onStoryLinkTapped.call(link.url);
} else {
LinkUtil.launch(link.url);
}
},
onOpen: (LinkableElement link) =>
LinkUtil.launch,
),
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.pop(context);
onSearchUserTapped(context);
},
child: const Text(
'Search',
),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
@ -163,6 +180,24 @@ class MorePopupMenu extends StatelessWidget {
),
onTap: context.read<VoteCubit>().downvote,
),
BlocBuilder<FavCubit, FavState>(
builder: (BuildContext context, FavState state) {
final bool isFav = state.favIds.contains(item.id);
return ListTile(
leading: Icon(
isFav ? Icons.favorite : Icons.favorite_border,
color: isFav ? Palette.orange : null,
),
title: Text(
isFav ? 'Unfavorite' : 'Favorite',
),
onTap: () => Navigator.pop(
context,
MenuAction.fav,
),
);
},
),
ListTile(
leading: const Icon(FeatherIcons.share),
title: const Text(
@ -213,4 +248,45 @@ class MorePopupMenu extends StatelessWidget {
),
);
}
void onSearchUserTapped(BuildContext context) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (BuildContext context) {
return BlocProvider<SearchCubit>(
create: (_) => SearchCubit()
..addFilter(
PostedByFilter(
author: item.by,
),
),
child: Container(
height: MediaQuery.of(context).size.height - Dimens.pt120,
color: Theme.of(context).canvasColor,
margin: const EdgeInsets.only(top: Dimens.pt12),
child: Material(
child: Column(
children: <Widget>[
Container(
height: Dimens.pt4,
width: Dimens.pt24,
decoration: BoxDecoration(
color: Palette.grey,
borderRadius: BorderRadius.circular(Dimens.pt16),
),
),
const Expanded(
child: SearchScreen(
fromUserDialog: true,
),
)
],
),
),
),
);
},
);
}
}

View File

@ -1,26 +1,21 @@
import 'dart:async';
import 'dart:math';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
class PinIconButton extends StatelessWidget {
const PinIconButton({
super.key,
required this.story,
required this.onBackgroundTap,
required this.onDismiss,
});
final Story story;
final Future<bool> Function() onBackgroundTap;
final Future<bool> Function() onDismiss;
@override
Widget build(BuildContext context) {
@ -33,15 +28,7 @@ class PinIconButton extends StatelessWidget {
offset: const Offset(2, 0),
child: IconButton(
tooltip: 'Pin to home screen',
icon: DescribedFeatureOverlay(
onBackgroundTap: onBackgroundTap,
onDismiss: onDismiss,
onComplete: () async {
unawaited(HapticFeedback.lightImpact());
return true;
},
overflowMode: OverflowMode.extendBackground,
targetColor: Theme.of(context).primaryColor,
icon: CustomDescribedFeatureOverlay(
tapTarget: Icon(
pinned ? Icons.push_pin : Icons.push_pin_outlined,
color: Palette.white,

View File

@ -4,18 +4,18 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:hacki/blocs/auth/auth_bloc.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/context_extension.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/styles/styles.dart';
class PollView extends StatelessWidget {
const PollView({
super.key,
required this.onLoginTapped,
});
class PollView extends StatefulWidget {
const PollView({super.key});
final VoidCallback onLoginTapped;
@override
State<PollView> createState() => _PollViewState();
}
class _PollViewState extends State<PollView> {
@override
Widget build(BuildContext context) {
return BlocBuilder<PollCubit, PollState>(

View File

@ -3,13 +3,12 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.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/extensions/extensions.dart';
import 'package:hacki/models/item.dart';
import 'package:hacki/models/item/item.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/link_util.dart';
class ReplyBox extends StatefulWidget {
const ReplyBox({
@ -256,6 +255,8 @@ class _ReplyBoxState extends State<ReplyBox> {
void showTextPopup() {
final Item? replyingTo = context.read<EditCubit>().state.replyingTo;
if (replyingTo == null) return;
showDialog<void>(
context: context,
builder: (_) {
@ -280,37 +281,49 @@ class _ReplyBoxState extends State<ReplyBox> {
child: Row(
children: <Widget>[
Text(
replyingTo?.by ?? '',
style: const TextStyle(color: Palette.grey),
replyingTo.by,
style: const TextStyle(
fontSize: TextDimens.pt14,
color: Palette.grey,
),
),
const Spacer(),
if (replyingTo != null)
TextButton(
child: const Text('View thread'),
onPressed: () {
HapticFeedback.lightImpact();
setState(() {
expanded = false;
});
Navigator.popUntil(
context,
(Route<dynamic> route) =>
route.settings.name == ItemScreen.routeName ||
route.isFirst,
);
goToItemScreen(
args: ItemScreenArgs(
item: replyingTo,
useCommentCache: true,
),
forceNewScreen: true,
);
},
),
TextButton(
child: const Text('Copy all'),
child: const Text(
'View thread',
style: TextStyle(
fontSize: TextDimens.pt14,
),
),
onPressed: () {
HapticFeedback.lightImpact();
setState(() {
expanded = false;
});
Navigator.popUntil(
context,
(Route<dynamic> route) =>
route.settings.name == ItemScreen.routeName ||
route.isFirst,
);
goToItemScreen(
args: ItemScreenArgs(
item: replyingTo,
useCommentCache: true,
),
forceNewScreen: true,
);
},
),
TextButton(
child: const Text(
'Copy all',
style: TextStyle(
fontSize: TextDimens.pt14,
),
),
onPressed: () => FlutterClipboard.copy(
replyingTo?.text ?? '',
replyingTo.text,
).then((_) => HapticFeedback.selectionClick()),
),
IconButton(
@ -334,17 +347,8 @@ class _ReplyBoxState extends State<ReplyBox> {
top: Dimens.pt6,
),
child: SingleChildScrollView(
child: SelectableLinkify(
scrollPhysics: const NeverScrollableScrollPhysics(),
textScaleFactor:
MediaQuery.of(context).textScaleFactor,
linkStyle: const TextStyle(
fontSize: TextDimens.pt15,
color: Palette.orange,
),
onOpen: (LinkableElement link) =>
LinkUtil.launch(link.url),
text: replyingTo?.text ?? '',
child: ItemText(
item: replyingTo,
),
),
),

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
@ -14,14 +13,12 @@ class TimeMachineDialog extends StatelessWidget {
required this.size,
required this.deviceType,
required this.widthFactor,
required this.onStoryLinkTapped,
});
final Comment comment;
final Size size;
final DeviceScreenType deviceType;
final double widthFactor;
final void Function(String) onStoryLinkTapped;
@override
Widget build(BuildContext context) {
@ -52,7 +49,7 @@ class TimeMachineDialog extends StatelessWidget {
const SizedBox(
width: Dimens.pt8,
),
const Text('Parents:'),
const Text('Ancestors:'),
const Spacer(),
IconButton(
icon: const Icon(
@ -67,12 +64,10 @@ class TimeMachineDialog extends StatelessWidget {
Expanded(
child: ListView(
children: <Widget>[
for (final Comment c in state.parents) ...<Widget>[
for (final Comment c
in state.ancestors) ...<Widget>[
CommentTile(
comment: c,
myUsername:
context.read<AuthBloc>().state.username,
onStoryLinkTapped: onStoryLinkTapped,
actionable: false,
fetchMode: FetchMode.eager,
),

View File

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
@ -34,15 +35,6 @@ class _ProfileScreenState extends State<ProfileScreen>
PageType pageType = PageType.notification;
final List<String> magicWords = <String>[
'to be a lord.',
'to conquer the world.',
'to be over the rainbow!',
'to bless humanity with long-lasting peace.',
'to save the world',
'to infinity and beyond!',
];
@override
void dispose() {
super.dispose();
@ -56,7 +48,6 @@ class _ProfileScreenState extends State<ProfileScreen>
@override
Widget build(BuildContext context) {
super.build(context);
final String magicWord = (magicWords..shuffle()).first;
return BlocBuilder<AuthBloc, AuthState>(
builder: (BuildContext context, AuthState authState) {
return BlocConsumer<NotificationCubit, NotificationState>(
@ -238,9 +229,8 @@ class _ProfileScreenState extends State<ProfileScreen>
),
Settings(
authState: authState,
magicWord: magicWord,
magicWord: Constants.magicWord,
pageType: pageType,
onLoginTapped: onLoginTapped,
),
Align(
alignment: Alignment.topLeft,

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

@ -32,13 +32,11 @@ class Settings extends StatefulWidget {
required this.authState,
required this.magicWord,
required this.pageType,
required this.onLoginTapped,
});
final AuthState authState;
final String magicWord;
final PageType pageType;
final VoidCallback onLoginTapped;
@override
State<Settings> createState() => _SettingsState();
@ -69,7 +67,7 @@ class _SettingsState extends State<Settings> {
if (widget.authState.isLoggedIn) {
onLogoutTapped();
} else {
widget.onLoginTapped();
onLoginTapped();
}
},
),
@ -219,6 +217,12 @@ class _SettingsState extends State<Settings> {
},
activeColor: Palette.orange,
),
ListTile(
title: const Text(
'Font',
),
onTap: showFontSettingDialog,
),
ListTile(
title: const Text(
'Theme',
@ -285,6 +289,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

@ -12,7 +12,15 @@ import 'package:hacki/utils/utils.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
class SearchScreen extends StatefulWidget {
const SearchScreen({super.key});
const SearchScreen({
super.key,
this.fromUserDialog = false,
});
/// If user gets to [SearchScreen] from user dialog on Tablet,
/// we navigate to [ItemScreen] directly instead of injecting the
/// item into [SplitViewCubit].
final bool fromUserDialog;
@override
_SearchScreenState createState() => _SearchScreenState();
@ -37,6 +45,7 @@ class _SearchScreenState extends State<SearchScreen> {
resizeToAvoidBottomInset: false,
body: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Padding(
padding: const EdgeInsets.symmetric(
@ -68,18 +77,13 @@ class _SearchScreenState extends State<SearchScreen> {
child: Row(
children: <Widget>[
const SizedBox(
width: 8,
width: Dimens.pt8,
),
DateTimeRangeFilterChip(
filter: state.params.get<DateTimeRangeFilter>(),
onDateTimeRangeUpdated:
(DateTime start, DateTime end) =>
context.read<SearchCubit>().addFilter(
DateTimeRangeFilter(
startTime: start,
endTime: end,
),
),
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
onDateTimeRangeRemoved: context
.read<SearchCubit>()
.removeFilter<DateTimeRangeFilter>,
@ -87,6 +91,14 @@ class _SearchScreenState extends State<SearchScreen> {
const SizedBox(
width: Dimens.pt8,
),
PostedByFilterChip(
filter: state.params.get<PostedByFilter>(),
onChanged:
context.read<SearchCubit>().onPostedByChanged,
),
const SizedBox(
width: Dimens.pt8,
),
CustomChip(
onSelected: (_) =>
context.read<SearchCubit>().onSortToggled(),
@ -100,13 +112,9 @@ class _SearchScreenState extends State<SearchScreen> {
in CustomDateTimeRange.values) ...<Widget>[
CustomRangeFilterChip(
range: range,
onTap: (DateTime start, DateTime end) =>
context.read<SearchCubit>().addFilter(
DateTimeRangeFilter(
startTime: start,
endTime: end,
),
),
onTap: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
),
const SizedBox(
width: Dimens.pt8,
@ -115,12 +123,52 @@ class _SearchScreenState extends State<SearchScreen> {
],
),
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: <Widget>[
for (final TypeTagFilter filter
in TypeTagFilter.all) ...<Widget>[
const SizedBox(
width: Dimens.pt8,
),
CustomChip(
onSelected: (_) =>
context.read<SearchCubit>().onToggled(filter),
selected: context
.read<SearchCubit>()
.state
.params
.get<TypeTagFilter>() ==
filter,
label: filter.query,
),
],
],
),
),
if (state.status == SearchStatus.loading &&
state.results.isEmpty) ...<Widget>[
const SizedBox(
height: Dimens.pt100,
),
const CustomCircularProgressIndicator(),
const Center(
child: CustomCircularProgressIndicator(),
),
],
if (state.status == SearchStatus.loaded &&
state.results.isEmpty) ...<Widget>[
const SizedBox(
height: Dimens.pt100,
),
const Center(
child: Text(
'Nothing found...',
style: TextStyle(
color: Palette.grey,
),
),
),
],
Expanded(
child: SmartRefresher(
@ -160,19 +208,33 @@ class _SearchScreenState extends State<SearchScreen> {
children: <Widget>[
...state.results
.map(
(Story e) => <Widget>[
FadeIn(
child: StoryTile(
showWebPreview:
prefState.complexStoryTileEnabled,
showMetadata: prefState.metadataEnabled,
showUrl: prefState.urlEnabled,
story: e,
onTap: () => goToItemScreen(
args: ItemScreenArgs(item: e),
(Item e) => <Widget>[
if (e is Story)
FadeIn(
child: StoryTile(
showWebPreview:
prefState.complexStoryTileEnabled,
showMetadata: prefState.metadataEnabled,
showUrl: prefState.urlEnabled,
story: e,
onTap: () => goToItemScreen(
args: ItemScreenArgs(item: e),
forceNewScreen: widget.fromUserDialog,
),
),
)
else if (e is Comment)
FadeIn(
child: CommentTile(
actionable: false,
comment: e,
fetchMode: FetchMode.eager,
onTap: () => goToItemScreen(
args: ItemScreenArgs(item: e),
forceNewScreen: widget.fromUserDialog,
),
),
),
),
if (!prefState.complexStoryTileEnabled)
const Divider(
height: Dimens.zero,

View File

@ -35,7 +35,7 @@ class DateTimeRangeFilterChip extends StatelessWidget {
},
selected: filter != null,
label:
'''from ${_formatDateTime(filter?.startTime) ?? 'START DATE'} to ${_formatDateTime(filter?.endTime) ?? 'END DATE'}''',
'''from ${_formatDateTime(filter?.startTime) ?? 'X'} to ${_formatDateTime(filter?.endTime) ?? 'Y'}''',
);
}

View File

@ -1,21 +1,107 @@
import 'package:flutter/material.dart';
import 'package:hacki/models/search_params.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
class PostedByFilterChip extends StatelessWidget {
const PostedByFilterChip({
super.key,
required this.filter,
required this.onChanged,
});
final PostedByFilter? filter;
final ValueChanged<String?> onChanged;
@override
Widget build(BuildContext context) {
return CustomChip(
onSelected: (bool value) {},
onSelected: (_) async {
final String? username = await onChipTapped(context);
if (username == filter?.author) {
return;
}
onChanged(username);
},
selected: filter != null,
label: '''posted by ${filter?.author ?? ''}''',
label: '''posted by ${filter?.author ?? ''}'''.trimRight(),
);
}
Future<String?> onChipTapped(BuildContext context) async {
final TextEditingController usernameController = TextEditingController();
if (filter?.author != null) {
usernameController.text = filter!.author;
}
final String? username = await showDialog<String?>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return SimpleDialog(
children: <Widget>[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt18,
),
child: TextField(
controller: usernameController,
cursorColor: Palette.orange,
autocorrect: false,
decoration: const InputDecoration(
hintText: 'Username',
focusedBorder: UnderlineInputBorder(
borderSide: BorderSide(color: Palette.orange),
),
),
),
),
const SizedBox(
height: Dimens.pt16,
),
Padding(
padding: const EdgeInsets.only(
right: Dimens.pt12,
),
child: ButtonBar(
children: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context, filter?.author),
child: const Text(
'Cancel',
),
),
TextButton(
onPressed: () => Navigator.pop(context, null),
child: const Text(
'Clear',
),
),
ElevatedButton(
onPressed: () {
final String text = usernameController.text.trim();
Navigator.pop(context, text.isEmpty ? null : text);
},
style: ButtonStyle(
backgroundColor:
MaterialStateProperty.all(Palette.deepOrange),
),
child: const Text(
'Confirm',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Palette.white,
),
),
),
],
),
),
],
);
},
);
return username;
}
}

View File

@ -1,24 +1,19 @@
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';
class CommentTile extends StatelessWidget {
const CommentTile({
super.key,
required this.myUsername,
required this.comment,
required this.onStoryLinkTapped,
required this.fetchMode,
this.onReplyTapped,
this.onMoreTapped,
@ -27,19 +22,22 @@ class CommentTile extends StatelessWidget {
this.opUsername,
this.actionable = true,
this.level = 0,
this.onTap,
});
final String? myUsername;
final String? opUsername;
final Comment comment;
final int level;
final bool actionable;
final FetchMode fetchMode;
final void Function(Comment)? onReplyTapped;
final void Function(Comment, Rect?)? onMoreTapped;
final void Function(Comment)? onEditTapped;
final void Function(Comment)? onRightMoreTapped;
final void Function(String) onStoryLinkTapped;
final FetchMode fetchMode;
/// Override for search screen.
final VoidCallback? onTap;
static final Map<int, Color> _colors = <int, Color>{};
@ -122,6 +120,8 @@ class CommentTile extends StatelessWidget {
if (actionable) {
HapticFeedback.selectionClick();
context.read<CollapseCubit>().collapse();
} else {
onTap?.call();
}
},
child: Column(
@ -188,10 +188,16 @@ class CommentTile extends StatelessWidget {
),
child: SizedBox(
width: double.infinity,
child: _CommentText(
child: ItemText(
key: ValueKey<int>(comment.id),
comment: comment,
onLinkTapped: _onLinkTapped,
item: comment,
onTap: () {
if (onTap == null) {
_onTextTapped(context);
} else {
onTap!.call();
}
},
),
),
),
@ -244,7 +250,8 @@ class CommentTile extends StatelessWidget {
final Color commentColor = prefState.eyeCandyEnabled
? color.withOpacity(commentBackgroundColorOpacity)
: Palette.transparent;
final bool isMyComment = myUsername == comment.by;
final bool isMyComment = comment.deleted == false &&
context.read<AuthBloc>().state.username == comment.by;
Widget wrapper = child;
@ -327,58 +334,7 @@ class CommentTile extends StatelessWidget {
commentsState?.onlyShowTargetComment == false;
}
void _onLinkTapped(LinkableElement link) {
if (link.url.isStoryLink) {
onStoryLinkTapped.call(link.url);
} else {
LinkUtil.launch(link.url);
}
}
}
class _CommentText extends StatelessWidget {
const _CommentText({
super.key,
required this.comment,
required this.onLinkTapped,
});
final Comment comment;
final void Function(LinkableElement) onLinkTapped;
@override
Widget build(BuildContext context) {
final PreferenceState prefState = context.read<PreferenceCubit>().state;
final TextStyle style = TextStyle(
fontSize: prefState.fontSize.fontSize,
);
final TextStyle linkStyle = TextStyle(
fontSize: prefState.fontSize.fontSize,
decoration: TextDecoration.underline,
color: Palette.orange,
);
if (comment is BuildableComment) {
return SelectableText.rich(
buildTextSpan(
(comment as BuildableComment).elements,
style: style,
linkStyle: linkStyle,
onOpen: onLinkTapped,
),
onTap: () => onTextTapped(context),
);
} else {
return SelectableLinkify(
text: comment.text,
style: style,
linkStyle: linkStyle,
onOpen: onLinkTapped,
onTap: () => onTextTapped(context),
);
}
}
void onTextTapped(BuildContext context) {
void _onTextTapped(BuildContext context) {
if (context.read<PreferenceCubit>().state.tapAnywhereToCollapseEnabled) {
HapticFeedback.selectionClick();
context.read<CollapseCubit>().collapse();

View File

@ -4,7 +4,7 @@ import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart' show ReminderCubit, ReminderState;
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/story.dart';
import 'package:hacki/models/item/story.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/styles/styles.dart';

View File

@ -0,0 +1,49 @@
import 'dart:async';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
class CustomDescribedFeatureOverlay extends StatelessWidget {
const CustomDescribedFeatureOverlay({
super.key,
required this.featureId,
required this.child,
required this.tapTarget,
required this.title,
required this.description,
this.onComplete,
});
final String featureId;
final Widget tapTarget;
final Widget title;
final Widget description;
final Widget child;
final VoidCallback? onComplete;
@override
Widget build(BuildContext context) {
return DescribedFeatureOverlay(
featureId: featureId,
overflowMode: OverflowMode.extendBackground,
targetColor: Theme.of(context).primaryColor,
tapTarget: tapTarget,
title: title,
description: description,
barrierDismissible: false,
onBackgroundTap: () {
unawaited(HapticFeedback.lightImpact());
FeatureDiscovery.completeCurrentStep(context);
onComplete?.call();
return Future<bool>.value(true);
},
onComplete: () async {
unawaited(HapticFeedback.lightImpact());
onComplete?.call();
return true;
},
child: child,
);
}
}

View File

@ -0,0 +1,403 @@
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,
this.contextMenuBuilder = _defaultContextMenuBuilder,
});
/// 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;
final EditableTextContextMenuBuilder? contextMenuBuilder;
@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,
contextMenuBuilder: contextMenuBuilder,
);
}
static Widget _defaultContextMenuBuilder(
BuildContext context,
EditableTextState editableTextState,
) {
return AdaptiveTextSelectionToolbar.editableText(
editableTextState: editableTextState,
);
}
}
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/material.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;
}

View File

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

View File

@ -0,0 +1,71 @@
import 'package:flutter/material.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;
}

View File

@ -1,18 +1,12 @@
import 'dart:async';
import 'package:badges/badges.dart';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart' hide Badge;
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/circle_tab_indicator.dart';
import 'package:hacki/screens/widgets/onboarding_view.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
class CustomTabBar extends StatefulWidget {
const CustomTabBar({
@ -27,11 +21,6 @@ class CustomTabBar extends StatefulWidget {
}
class _CustomTabBarState extends State<CustomTabBar> {
final Throttle featureDiscoveryDismissThrottle = Throttle(
delay: _throttleDelay,
);
static const Duration _throttleDelay = Duration(seconds: 1);
late List<StoryType> tabs = context.read<TabCubit>().state.tabs;
int currentIndex = 0;
@ -87,17 +76,8 @@ class _CustomTabBarState extends State<CustomTabBar> {
),
),
Tab(
child: DescribedFeatureOverlay(
onBackgroundTap: onFeatureDiscoveryDismissed,
onDismiss: onFeatureDiscoveryDismissed,
onComplete: () async {
ScaffoldMessenger.of(context).clearSnackBars();
unawaited(HapticFeedback.lightImpact());
showOnboarding();
return true;
},
overflowMode: OverflowMode.extendBackground,
targetColor: Theme.of(context).primaryColor,
child: CustomDescribedFeatureOverlay(
onComplete: showOnboarding,
tapTarget: const Icon(
Icons.person,
size: TextDimens.pt16,
@ -162,20 +142,4 @@ class _CustomTabBarState extends State<CustomTabBar> {
),
);
}
Future<bool> onFeatureDiscoveryDismissed() {
featureDiscoveryDismissThrottle.run(() {
HapticFeedback.lightImpact();
ScaffoldMessenger.of(context).clearSnackBars();
showSnackBar(content: 'Tap on icon to continue');
});
return Future<bool>.value(false);
}
@override
void dispose() {
featureDiscoveryDismissThrottle.dispose();
super.dispose();
}
}

View File

@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
class ItemText extends StatelessWidget {
const ItemText({
super.key,
required this.item,
this.onTap,
});
final Item item;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final PreferenceState prefState = context.read<PreferenceCubit>().state;
final TextStyle style = TextStyle(
fontSize: prefState.fontSize.fontSize,
);
final TextStyle linkStyle = TextStyle(
fontSize: prefState.fontSize.fontSize,
decoration: TextDecoration.underline,
color: Palette.orange,
);
if (item is Buildable) {
return SelectableText.rich(
buildTextSpan(
(item as Buildable).elements,
style: style,
linkStyle: linkStyle,
onOpen: (LinkableElement link) => LinkUtil.launch(link.url),
),
onTap: onTap,
textScaleFactor: MediaQuery.of(context).textScaleFactor,
contextMenuBuilder: (
BuildContext context,
EditableTextState editableTextState,
) =>
contextMenuBuilder(
context,
editableTextState,
item: item,
),
);
} else {
return SelectableLinkify(
text: item.text,
textScaleFactor: MediaQuery.of(context).textScaleFactor,
style: style,
linkStyle: linkStyle,
onOpen: (LinkableElement link) => LinkUtil.launch(link.url),
onTap: onTap,
contextMenuBuilder: (
BuildContext context,
EditableTextState editableTextState,
) =>
contextMenuBuilder(
context,
editableTextState,
item: item,
),
);
}
}
}

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

@ -5,7 +5,10 @@ export 'comment_tile.dart';
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 'item_text.dart';
export 'items_list_view.dart';
export 'link_preview/link_preview.dart';
export 'offline_banner.dart';

View File

@ -6,7 +6,8 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/utils/html_util.dart';
import 'package:hacki/utils/utils.dart';
import 'package:logger/logger.dart';
import 'package:path_provider_android/path_provider_android.dart';
import 'package:path_provider_foundation/path_provider_foundation.dart';
import 'package:shared_preferences_android/shared_preferences_android.dart';
@ -34,14 +35,21 @@ abstract class Fetcher {
static const int _subscriptionUpperLimit = 15;
static Future<void> fetchReplies() async {
final PreferenceRepository preferenceRepository = PreferenceRepository();
final Logger logger = Logger();
final PreferenceRepository preferenceRepository =
PreferenceRepository(logger: logger);
final AuthRepository authRepository = AuthRepository(
preferenceRepository: preferenceRepository,
logger: logger,
);
final StoriesRepository storiesRepository = StoriesRepository();
final SembastRepository sembastRepository = SembastRepository();
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
final String? username = await authRepository.username;
final List<int> unreadIds = await preferenceRepository.unreadCommentsIds;

View File

@ -4,9 +4,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/main.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/screens.dart' show WebViewScreen;
import 'package:hacki/screens/screens.dart'
show ItemScreen, ItemScreenArgs, WebViewScreen;
import 'package:hacki/styles/styles.dart';
import 'package:url_launcher/url_launcher.dart';
@ -45,6 +48,11 @@ abstract class LinkUtil {
return;
}
if (link.isStoryLink) {
_onStoryLinkTapped(link);
return;
}
Uri rinseLink(String link) {
final RegExp regex = RegExp(RegExpConstants.linkSuffix);
if (!link.contains('en.wikipedia.org') && link.contains(regex)) {
@ -80,4 +88,23 @@ abstract class LinkUtil {
}
});
}
static Future<void> _onStoryLinkTapped(String link) async {
final int? id = link.itemId;
if (id != null) {
await locator
.get<StoriesRepository>()
.fetchItem(id: id)
.then((Item? item) {
if (item != null) {
HackiApp.navigatorKey.currentState!.pushNamed(
ItemScreen.routeName,
arguments: ItemScreenArgs(item: item),
);
}
});
} else {
launch(link);
}
}
}

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

@ -332,14 +332,6 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct main"
description:
@ -569,7 +561,7 @@ packages:
source: hosted
version: "0.6.5"
linkify:
dependency: transitive
dependency: "direct main"
description:
name: linkify
sha256: bdfbdafec6cdc9cd0ebb333a868cafc046714ad508e48be8095208c54691d959
@ -1359,4 +1351,4 @@ packages:
version: "3.1.1"
sdks:
dart: ">=2.19.0 <3.0.0"
flutter: ">=3.7.3"
flutter: ">=3.7.5"

View File

@ -1,11 +1,11 @@
name: hacki
description: A Hacker News reader.
version: 1.0.11+89
version: 1.2.1+94
publish_to: none
environment:
sdk: ">=2.17.0 <3.0.0"
flutter: "3.7.3"
flutter: "3.7.5"
dependencies:
adaptive_theme: ^3.0.0
@ -30,7 +30,6 @@ dependencies:
flutter_fadein: ^2.0.0
flutter_feather_icons: 2.0.0+1
flutter_inappwebview: ^5.7.2+3
flutter_linkify: ^5.0.2
flutter_local_notifications: ^13.0.0
flutter_secure_storage: ^8.0.0
flutter_siri_suggestions: ^2.1.0
@ -44,6 +43,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 +90,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