Compare commits

..

8 Commits

Author SHA1 Message Date
32ae2087bc fix link button. (#171) 2023-02-26 23:03:48 -08:00
0b5329d050 bugfixes. (#170) 2023-02-26 15:08:18 -08:00
c375def289 bugfixes. (#169) 2023-02-26 12:12:11 -08:00
3469543c7b update action menu. (#168) 2023-02-26 02:40:11 -08:00
ab755581fd add favorite to action menu. (#167) 2023-02-25 23:16:55 -08:00
6b75eb8549 bump version. (#165) 2023-02-24 11:41:19 -08:00
36ded8a8e3 improve search experience. (#164) 2023-02-24 10:38:10 -08:00
582ac7b0be fix push notification. (#161) 2023-02-23 23:14:06 -08:00
45 changed files with 795 additions and 391 deletions

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

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

@ -76,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,
),
@ -89,8 +89,8 @@ 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()
@ -111,7 +111,8 @@ 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));
@ -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;
@ -380,6 +382,19 @@ class CommentsCubit extends Cubit<CommentsState> {
}
}
static Future<Item?> _toBuildable(Item? item) async {
if (item == null) return null;
switch (item.runtimeType) {
case Comment:
return _toBuildableComment(item as Comment);
case Story:
return _toBuildableStory(item as Story);
}
return null;
}
static Future<BuildableComment?> _toBuildableComment(Comment? comment) async {
if (comment == null) return null;
@ -395,6 +410,25 @@ class CommentsCubit extends Cubit<CommentsState> {
return buildableComment;
}
static Future<BuildableStory?> _toBuildableStory(Story? story) async {
if (story == null) {
return null;
} else if (story.text.isEmpty) {
return BuildableStory.fromTitleOnlyStory(story);
}
final List<LinkifyElement> elements =
await compute<String, List<LinkifyElement>>(
LinkifierUtil.linkify,
story.text,
);
final BuildableStory buildableStory =
BuildableStory.fromStory(story, elements: elements);
return buildableStory;
}
@override
Future<void> close() async {
await _streamSubscription?.cancel();

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

@ -15,10 +15,8 @@ extension WidgetModifier on Widget {
Widget contextMenuBuilder(
BuildContext context,
EditableTextState editableTextState, {
required BuildableComment comment,
required Item item,
}) {
final Iterable<EmphasisElement> emphasisElements =
comment.elements.whereType<EmphasisElement>();
final int start = editableTextState.textEditingValue.selection.base.offset;
final int end = editableTextState.textEditingValue.selection.end;
@ -27,22 +25,27 @@ extension WidgetModifier on Widget {
];
if (start != -1 && end != -1) {
String selectedText = comment.text.substring(start, end);
String selectedText = item.text.substring(start, end);
int count = 1;
while (selectedText.contains(' ') && count <= emphasisElements.length) {
final int s = (start + count * 2).clamp(0, comment.text.length);
final int e = (end + count * 2).clamp(0, comment.text.length);
selectedText = comment.text.substring(s, e);
count++;
}
if (item is Buildable) {
final Iterable<EmphasisElement> emphasisElements =
(item as Buildable).elements.whereType<EmphasisElement>();
count = 1;
while (selectedText.contains(' ') && count <= emphasisElements.length) {
final int s = (start - count * 2).clamp(0, comment.text.length);
final int e = (end - count * 2).clamp(0, comment.text.length);
selectedText = comment.text.substring(s, e);
count++;
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>[

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: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({

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 {

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({

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({

View File

@ -1,14 +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

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

@ -74,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,
),
),
@ -116,7 +116,7 @@ class ItemScreen extends StatefulWidget {
context.read<PreferenceCubit>().state.order,
)..init(
onlyShowTargetComment: args.onlyShowTargetComment,
targetParents: args.targetComments,
targetAncestors: args.targetComments,
),
),
],
@ -289,8 +289,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
topPadding: topPadding,
splitViewEnabled: widget.splitViewEnabled,
onMoreTapped: onMoreTapped,
onStoryLinkTapped: onStoryLinkTapped,
onLoginTapped: onLoginTapped,
onRightMoreTapped: onRightMoreTapped,
),
),
@ -365,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(
@ -453,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);
@ -497,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

@ -33,8 +33,10 @@ class LinkIconButton extends StatelessWidget {
Icons.stream,
),
),
onPressed: () =>
LinkUtil.launch('https://news.ycombinator.com/item?id=$storyId'),
onPressed: () => LinkUtil.launch(
'https://news.ycombinator.com/item?id=$storyId',
useHackiForHnLink: false,
),
);
}
}

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

@ -25,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,
});
@ -38,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;
@ -123,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) {
@ -149,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) {
@ -177,7 +169,6 @@ class MainView extends StatelessWidget {
focusNode.requestFocus();
},
onMoreTapped: onMoreTapped,
onStoryLinkTapped: onStoryLinkTapped,
onRightMoreTapped: onRightMoreTapped,
),
);
@ -224,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,
});
@ -238,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
@ -391,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,
),
),
),
@ -428,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,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
@ -6,6 +8,7 @@ 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(link.url),
),
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

@ -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>(
@ -62,29 +62,29 @@ class PollView extends StatelessWidget {
listener: (BuildContext context, VoteState voteState) {
ScaffoldMessenger.of(context).clearSnackBars();
if (voteState.status == VoteStatus.submitted) {
context.showSnackBar(
showSnackBar(
content: 'Vote submitted successfully.',
);
} else if (voteState.status == VoteStatus.canceled) {
context.showSnackBar(content: 'Vote canceled.');
showSnackBar(content: 'Vote canceled.');
} else if (voteState.status == VoteStatus.failure) {
context.showErrorSnackBar();
showErrorSnackBar();
} else if (voteState.status ==
VoteStatus.failureKarmaBelowThreshold) {
context.showSnackBar(
showSnackBar(
content: "You can't downvote because"
' you are karmaly broke.',
);
} else if (voteState.status ==
VoteStatus.failureNotLoggedIn) {
context.showSnackBar(
showSnackBar(
content: 'Not logged in, no voting! (;O´)o',
action: onLoginTapped,
label: 'Log in',
);
} else if (voteState.status ==
VoteStatus.failureBeHumble) {
context.showSnackBar(
showSnackBar(
content: 'No voting on your own post! (;O´)o',
);
}

View File

@ -5,11 +5,10 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.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

@ -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();
}
},
),

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

@ -9,14 +9,11 @@ import 'package:hacki/models/models.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,
@ -25,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>{};
@ -120,6 +120,8 @@ class CommentTile extends StatelessWidget {
if (actionable) {
HapticFeedback.selectionClick();
context.read<CollapseCubit>().collapse();
} else {
onTap?.call();
}
},
child: Column(
@ -186,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();
}
},
),
),
),
@ -242,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;
@ -325,67 +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),
contextMenuBuilder: (
BuildContext context,
EditableTextState editableTextState,
) =>
contextMenuBuilder(
context,
editableTextState,
comment: comment as BuildableComment,
),
);
} 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/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/styles/styles.dart';

View File

@ -181,6 +181,7 @@ class SelectableLinkify extends StatelessWidget {
this.cursorHeight,
this.selectionControls,
this.onSelectionChanged,
this.contextMenuBuilder = _defaultContextMenuBuilder,
});
/// Text to be linkified
@ -273,6 +274,8 @@ class SelectableLinkify extends StatelessWidget {
/// cursor location).
final SelectionChangedCallback? onSelectionChanged;
final EditableTextContextMenuBuilder? contextMenuBuilder;
@override
Widget build(BuildContext context) {
final List<LinkifyElement> elements = LinkifierUtil.linkify(text);
@ -312,6 +315,16 @@ class SelectableLinkify extends StatelessWidget {
cursorHeight: cursorHeight,
selectionControls: selectionControls,
onSelectionChanged: onSelectionChanged,
contextMenuBuilder: contextMenuBuilder,
);
}
static Widget _defaultContextMenuBuilder(
BuildContext context,
EditableTextState editableTextState,
) {
return AdaptiveTextSelectionToolbar.editableText(
editableTextState: editableTextState,
);
}
}

View File

@ -1,4 +1,4 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:linkify/linkify.dart';
final RegExp _emphasisRegex = RegExp(

View File

@ -1,4 +1,4 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:linkify/linkify.dart';
final RegExp _quoteRegex = RegExp(

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

@ -8,6 +8,7 @@ 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';
@ -27,6 +30,7 @@ abstract class LinkUtil {
String link, {
bool useReader = false,
bool offlineReading = false,
bool useHackiForHnLink = true,
}) {
if (offlineReading) {
locator
@ -45,6 +49,11 @@ abstract class LinkUtil {
return;
}
if (useHackiForHnLink && 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 +89,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, useHackiForHnLink: false);
}
}
}

View File

@ -1,6 +1,6 @@
name: hacki
description: A Hacker News reader.
version: 1.1.1+92
version: 1.2.3+96
publish_to: none
environment: