Compare commits

...

4 Commits

Author SHA1 Message Date
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
32 changed files with 372 additions and 291 deletions

View File

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

View File

@ -111,7 +111,8 @@ class CommentsCubit extends Cubit<CommentsState> {
final Item item = state.item; final Item item = state.item;
final Item updatedItem = state.offlineReading final Item updatedItem = state.offlineReading
? item ? item
: await _storiesRepository.fetchItem(id: item.id) ?? item; : await _storiesRepository.fetchItem(id: item.id).then(_toBuildable) ??
item;
final List<int> kids = sortKids(updatedItem.kids); final List<int> kids = sortKids(updatedItem.kids);
emit(state.copyWith(item: updatedItem)); emit(state.copyWith(item: updatedItem));
@ -273,8 +274,9 @@ class CommentsCubit extends Cubit<CommentsState> {
Future<void> loadParentThread() async { Future<void> loadParentThread() async {
unawaited(HapticFeedback.lightImpact()); unawaited(HapticFeedback.lightImpact());
emit(state.copyWith(fetchParentStatus: CommentsStatus.loading)); emit(state.copyWith(fetchParentStatus: CommentsStatus.loading));
final Story? parent = final Story? parent = await _storiesRepository
await _storiesRepository.fetchParentStory(id: state.item.id); .fetchParentStory(id: state.item.id)
.then(_toBuildableStory);
if (parent == null) { if (parent == null) {
return; 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 { static Future<BuildableComment?> _toBuildableComment(Comment? comment) async {
if (comment == null) return null; if (comment == null) return null;
@ -395,6 +410,25 @@ class CommentsCubit extends Cubit<CommentsState> {
return buildableComment; 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 @override
Future<void> close() async { Future<void> close() async {
await _streamSubscription?.cancel(); await _streamSubscription?.cancel();

View File

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

View File

@ -15,10 +15,8 @@ extension WidgetModifier on Widget {
Widget contextMenuBuilder( Widget contextMenuBuilder(
BuildContext context, BuildContext context,
EditableTextState editableTextState, { 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 start = editableTextState.textEditingValue.selection.base.offset;
final int end = editableTextState.textEditingValue.selection.end; final int end = editableTextState.textEditingValue.selection.end;
@ -27,22 +25,27 @@ extension WidgetModifier on Widget {
]; ];
if (start != -1 && end != -1) { if (start != -1 && end != -1) {
String selectedText = comment.text.substring(start, end); String selectedText = item.text.substring(start, end);
int count = 1; if (item is Buildable) {
while (selectedText.contains(' ') && count <= emphasisElements.length) { final Iterable<EmphasisElement> emphasisElements =
final int s = (start + count * 2).clamp(0, comment.text.length); (item as Buildable).elements.whereType<EmphasisElement>();
final int e = (end + count * 2).clamp(0, comment.text.length);
selectedText = comment.text.substring(s, e);
count++;
}
count = 1; int count = 1;
while (selectedText.contains(' ') && count <= emphasisElements.length) { while (selectedText.contains(' ') && count <= emphasisElements.length) {
final int s = (start - count * 2).clamp(0, comment.text.length); final int s = (start + count * 2).clamp(0, item.text.length);
final int e = (end - count * 2).clamp(0, comment.text.length); final int e = (end + count * 2).clamp(0, item.text.length);
selectedText = comment.text.substring(s, e); selectedText = item.text.substring(s, e);
count++; 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>[ 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/item/buildable.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/item/comment.dart';
import 'package:linkify/linkify.dart'; import 'package:linkify/linkify.dart';
/// [BuildableComment] is a subtype of [Comment] which stores /// [BuildableComment] is a subtype of [Comment] which stores
/// the corresponding [LinkifyElement] for faster widget building. /// the corresponding [LinkifyElement] for faster widget building.
class BuildableComment extends Comment { class BuildableComment extends Comment with Buildable {
BuildableComment({ BuildableComment({
required super.id, required super.id,
required super.time, required super.time,
@ -33,5 +33,6 @@ class BuildableComment extends Comment {
level: comment.level, level: comment.level,
); );
@override
final List<LinkifyElement> elements; 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 { class Comment extends Item {
Comment({ Comment({

View File

@ -1,8 +1,15 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:hacki/extensions/date_time_extension.dart'; import 'package:hacki/extensions/date_time_extension.dart';
import 'package:hacki/models/comment.dart'; import 'package:hacki/models/item/comment.dart';
import 'package:hacki/models/poll_option.dart'; import 'package:hacki/models/item/poll_option.dart';
import 'package:hacki/models/story.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]. /// [Item] is the base type of [Story], [Comment] and [PollOption].
class Item extends Equatable { class Item extends Equatable {

View File

@ -1,6 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:hacki/models/item.dart'; import 'package:hacki/models/item/item.dart';
class PollOption extends Item { class PollOption extends Item {
const PollOption({ const PollOption({

View File

@ -1,5 +1,5 @@
import 'package:hacki/config/constants.dart'; import 'package:hacki/config/constants.dart';
import 'package:hacki/models/item.dart'; import 'package:hacki/models/item/item.dart';
class Story extends Item { class Story extends Item {
const Story({ const Story({

View File

@ -1,14 +1,10 @@
export 'buildable_comment.dart';
export 'comment.dart';
export 'comments_order.dart'; export 'comments_order.dart';
export 'fetch_mode.dart'; export 'fetch_mode.dart';
export 'font.dart'; export 'font.dart';
export 'font_size.dart'; export 'font_size.dart';
export 'item.dart'; export 'item/item.dart';
export 'poll_option.dart';
export 'post_data.dart'; export 'post_data.dart';
export 'preference.dart'; export 'preference.dart';
export 'search_params.dart'; export 'search_params.dart';
export 'story.dart';
export 'story_type.dart'; export 'story_type.dart';
export 'user.dart'; export 'user.dart';

View File

@ -289,8 +289,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
topPadding: topPadding, topPadding: topPadding,
splitViewEnabled: widget.splitViewEnabled, splitViewEnabled: widget.splitViewEnabled,
onMoreTapped: onMoreTapped, onMoreTapped: onMoreTapped,
onStoryLinkTapped: onStoryLinkTapped,
onLoginTapped: onLoginTapped,
onRightMoreTapped: onRightMoreTapped, onRightMoreTapped: onRightMoreTapped,
), ),
), ),
@ -365,8 +363,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
topPadding: topPadding, topPadding: topPadding,
splitViewEnabled: widget.splitViewEnabled, splitViewEnabled: widget.splitViewEnabled,
onMoreTapped: onMoreTapped, onMoreTapped: onMoreTapped,
onStoryLinkTapped: onStoryLinkTapped,
onLoginTapped: onLoginTapped,
onRightMoreTapped: onRightMoreTapped, onRightMoreTapped: onRightMoreTapped,
), ),
bottomSheet: ReplyBox( bottomSheet: ReplyBox(
@ -497,7 +493,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
size: size, size: size,
deviceType: deviceType, deviceType: deviceType,
widthFactor: widthFactor, widthFactor: widthFactor,
onStoryLinkTapped: onStoryLinkTapped,
); );
}, },
); );

View File

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

View File

@ -2,25 +2,21 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart'; import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart'; import 'package:hacki/config/constants.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/screens/widgets/widgets.dart'; import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart'; import 'package:hacki/utils/utils.dart';
class LoginDialog extends StatelessWidget { class LoginDialog extends StatefulWidget {
const LoginDialog({ const LoginDialog({super.key});
super.key,
required this.usernameController,
required this.passwordController,
required this.showSnackBar,
});
final TextEditingController usernameController; @override
final TextEditingController passwordController; State<LoginDialog> createState() => _LoginDialogState();
final void Function({ }
required String content,
VoidCallback? action, class _LoginDialogState extends State<LoginDialog> {
String? label, final TextEditingController usernameController = TextEditingController();
}) showSnackBar; final TextEditingController passwordController = TextEditingController();
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {

View File

@ -25,8 +25,6 @@ class MainView extends StatelessWidget {
required this.topPadding, required this.topPadding,
required this.splitViewEnabled, required this.splitViewEnabled,
required this.onMoreTapped, required this.onMoreTapped,
required this.onStoryLinkTapped,
required this.onLoginTapped,
required this.onRightMoreTapped, required this.onRightMoreTapped,
}); });
@ -38,8 +36,6 @@ class MainView extends StatelessWidget {
final double topPadding; final double topPadding;
final bool splitViewEnabled; final bool splitViewEnabled;
final void Function(Item item, Rect? rect) onMoreTapped; final void Function(Item item, Rect? rect) onMoreTapped;
final ValueChanged<String> onStoryLinkTapped;
final VoidCallback onLoginTapped;
final ValueChanged<Comment> onRightMoreTapped; final ValueChanged<Comment> onRightMoreTapped;
static const int _loadingIndicatorOpacityAnimationDuration = 300; static const int _loadingIndicatorOpacityAnimationDuration = 300;
@ -123,8 +119,6 @@ class MainView extends StatelessWidget {
topPadding: topPadding, topPadding: topPadding,
splitViewEnabled: splitViewEnabled, splitViewEnabled: splitViewEnabled,
onMoreTapped: onMoreTapped, onMoreTapped: onMoreTapped,
onStoryLinkTapped: onStoryLinkTapped,
onLoginTapped: onLoginTapped,
onRightMoreTapped: onRightMoreTapped, onRightMoreTapped: onRightMoreTapped,
); );
} else if (index == state.comments.length + 1) { } else if (index == state.comments.length + 1) {
@ -149,8 +143,6 @@ class MainView extends StatelessWidget {
child: CommentTile( child: CommentTile(
comment: comment, comment: comment,
level: comment.level, level: comment.level,
myUsername:
authState.isLoggedIn ? authState.username : null,
opUsername: state.item.by, opUsername: state.item.by,
fetchMode: state.fetchMode, fetchMode: state.fetchMode,
onReplyTapped: (Comment cmt) { onReplyTapped: (Comment cmt) {
@ -177,7 +169,6 @@ class MainView extends StatelessWidget {
focusNode.requestFocus(); focusNode.requestFocus();
}, },
onMoreTapped: onMoreTapped, onMoreTapped: onMoreTapped,
onStoryLinkTapped: onStoryLinkTapped,
onRightMoreTapped: onRightMoreTapped, onRightMoreTapped: onRightMoreTapped,
), ),
); );
@ -224,8 +215,6 @@ class _ParentItemSection extends StatelessWidget {
required this.topPadding, required this.topPadding,
required this.splitViewEnabled, required this.splitViewEnabled,
required this.onMoreTapped, required this.onMoreTapped,
required this.onStoryLinkTapped,
required this.onLoginTapped,
required this.onRightMoreTapped, required this.onRightMoreTapped,
}); });
@ -238,8 +227,6 @@ class _ParentItemSection extends StatelessWidget {
final double topPadding; final double topPadding;
final bool splitViewEnabled; final bool splitViewEnabled;
final void Function(Item item, Rect? rect) onMoreTapped; final void Function(Item item, Rect? rect) onMoreTapped;
final ValueChanged<String> onStoryLinkTapped;
final VoidCallback onLoginTapped;
final ValueChanged<Comment> onRightMoreTapped; final ValueChanged<Comment> onRightMoreTapped;
@override @override
@ -391,32 +378,8 @@ class _ParentItemSection extends StatelessWidget {
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt10, horizontal: Dimens.pt10,
), ),
child: SelectableLinkify( child: ItemText(
text: state.item.text, item: state.item,
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);
}
},
), ),
), ),
), ),
@ -428,9 +391,7 @@ class _ParentItemSection extends StatelessWidget {
BlocProvider<PollCubit>( BlocProvider<PollCubit>(
create: (BuildContext context) => create: (BuildContext context) =>
PollCubit(story: state.item as Story)..init(), PollCubit(story: state.item as Story)..init(),
child: PollView( child: const PollView(),
onLoginTapped: onLoginTapped,
),
), ),
], ],
), ),

View File

@ -1,3 +1,5 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart'; import 'package:flutter_feather_icons/flutter_feather_icons.dart';
@ -16,15 +18,24 @@ class MorePopupMenu extends StatelessWidget {
super.key, super.key,
required this.item, required this.item,
required this.isBlocked, required this.isBlocked,
required this.onStoryLinkTapped,
required this.onLoginTapped, required this.onLoginTapped,
}); });
final Item item; final Item item;
final bool isBlocked; final bool isBlocked;
final ValueChanged<String> onStoryLinkTapped;
final VoidCallback onLoginTapped; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider<VoteCubit>( return BlocProvider<VoteCubit>(
@ -69,7 +80,7 @@ class MorePopupMenu extends StatelessWidget {
final bool upvoted = voteState.vote == Vote.up; final bool upvoted = voteState.vote == Vote.up;
final bool downvoted = voteState.vote == Vote.down; final bool downvoted = voteState.vote == Vote.down;
return Container( return Container(
height: item is Comment ? 430 : 450, height: item is Comment ? commentHeight : storyHeight,
color: Theme.of(context).canvasColor, color: Theme.of(context).canvasColor,
child: Material( child: Material(
color: Palette.transparent, color: Palette.transparent,
@ -114,13 +125,8 @@ class MorePopupMenu extends StatelessWidget {
linkStyle: const TextStyle( linkStyle: const TextStyle(
color: Palette.orange, color: Palette.orange,
), ),
onOpen: (LinkableElement link) { onOpen: (LinkableElement link) =>
if (link.url.isStoryLink) { LinkUtil.launch(link.url),
onStoryLinkTapped.call(link.url);
} else {
LinkUtil.launch(link.url);
}
},
), ),
actions: <Widget>[ actions: <Widget>[
TextButton( TextButton(
@ -174,6 +180,24 @@ class MorePopupMenu extends StatelessWidget {
), ),
onTap: context.read<VoteCubit>().downvote, 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( ListTile(
leading: const Icon(FeatherIcons.share), leading: const Icon(FeatherIcons.share),
title: const Text( title: const Text(

View File

@ -4,18 +4,18 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart'; import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:hacki/blocs/auth/auth_bloc.dart'; import 'package:hacki/blocs/auth/auth_bloc.dart';
import 'package:hacki/cubits/cubits.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/models/models.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
class PollView extends StatelessWidget { class PollView extends StatefulWidget {
const PollView({ const PollView({super.key});
super.key,
required this.onLoginTapped,
});
final VoidCallback onLoginTapped; @override
State<PollView> createState() => _PollViewState();
}
class _PollViewState extends State<PollView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<PollCubit, PollState>( return BlocBuilder<PollCubit, PollState>(
@ -62,29 +62,29 @@ class PollView extends StatelessWidget {
listener: (BuildContext context, VoteState voteState) { listener: (BuildContext context, VoteState voteState) {
ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).clearSnackBars();
if (voteState.status == VoteStatus.submitted) { if (voteState.status == VoteStatus.submitted) {
context.showSnackBar( showSnackBar(
content: 'Vote submitted successfully.', content: 'Vote submitted successfully.',
); );
} else if (voteState.status == VoteStatus.canceled) { } else if (voteState.status == VoteStatus.canceled) {
context.showSnackBar(content: 'Vote canceled.'); showSnackBar(content: 'Vote canceled.');
} else if (voteState.status == VoteStatus.failure) { } else if (voteState.status == VoteStatus.failure) {
context.showErrorSnackBar(); showErrorSnackBar();
} else if (voteState.status == } else if (voteState.status ==
VoteStatus.failureKarmaBelowThreshold) { VoteStatus.failureKarmaBelowThreshold) {
context.showSnackBar( showSnackBar(
content: "You can't downvote because" content: "You can't downvote because"
' you are karmaly broke.', ' you are karmaly broke.',
); );
} else if (voteState.status == } else if (voteState.status ==
VoteStatus.failureNotLoggedIn) { VoteStatus.failureNotLoggedIn) {
context.showSnackBar( showSnackBar(
content: 'Not logged in, no voting! (;O´)o', content: 'Not logged in, no voting! (;O´)o',
action: onLoginTapped, action: onLoginTapped,
label: 'Log in', label: 'Log in',
); );
} else if (voteState.status == } else if (voteState.status ==
VoteStatus.failureBeHumble) { VoteStatus.failureBeHumble) {
context.showSnackBar( showSnackBar(
content: 'No voting on your own post! (;O´)o', 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:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart'; import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/item.dart'; import 'package:hacki/models/item/item.dart';
import 'package:hacki/screens/screens.dart'; import 'package:hacki/screens/screens.dart';
import 'package:hacki/screens/widgets/widgets.dart'; import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/link_util.dart';
class ReplyBox extends StatefulWidget { class ReplyBox extends StatefulWidget {
const ReplyBox({ const ReplyBox({
@ -256,6 +255,8 @@ class _ReplyBoxState extends State<ReplyBox> {
void showTextPopup() { void showTextPopup() {
final Item? replyingTo = context.read<EditCubit>().state.replyingTo; final Item? replyingTo = context.read<EditCubit>().state.replyingTo;
if (replyingTo == null) return;
showDialog<void>( showDialog<void>(
context: context, context: context,
builder: (_) { builder: (_) {
@ -280,37 +281,49 @@ class _ReplyBoxState extends State<ReplyBox> {
child: Row( child: Row(
children: <Widget>[ children: <Widget>[
Text( Text(
replyingTo?.by ?? '', replyingTo.by,
style: const TextStyle(color: Palette.grey), style: const TextStyle(
fontSize: TextDimens.pt14,
color: Palette.grey,
),
), ),
const Spacer(), 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( 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( onPressed: () => FlutterClipboard.copy(
replyingTo?.text ?? '', replyingTo.text,
).then((_) => HapticFeedback.selectionClick()), ).then((_) => HapticFeedback.selectionClick()),
), ),
IconButton( IconButton(
@ -334,17 +347,8 @@ class _ReplyBoxState extends State<ReplyBox> {
top: Dimens.pt6, top: Dimens.pt6,
), ),
child: SingleChildScrollView( child: SingleChildScrollView(
child: SelectableLinkify( child: ItemText(
scrollPhysics: const NeverScrollableScrollPhysics(), item: replyingTo,
textScaleFactor:
MediaQuery.of(context).textScaleFactor,
linkStyle: const TextStyle(
fontSize: TextDimens.pt15,
color: Palette.orange,
),
onOpen: (LinkableElement link) =>
LinkUtil.launch(link.url),
text: replyingTo?.text ?? '',
), ),
), ),
), ),

View File

@ -1,6 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart'; import 'package:hacki/screens/widgets/widgets.dart';
@ -14,14 +13,12 @@ class TimeMachineDialog extends StatelessWidget {
required this.size, required this.size,
required this.deviceType, required this.deviceType,
required this.widthFactor, required this.widthFactor,
required this.onStoryLinkTapped,
}); });
final Comment comment; final Comment comment;
final Size size; final Size size;
final DeviceScreenType deviceType; final DeviceScreenType deviceType;
final double widthFactor; final double widthFactor;
final void Function(String) onStoryLinkTapped;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -71,9 +68,6 @@ class TimeMachineDialog extends StatelessWidget {
in state.ancestors) ...<Widget>[ in state.ancestors) ...<Widget>[
CommentTile( CommentTile(
comment: c, comment: c,
myUsername:
context.read<AuthBloc>().state.username,
onStoryLinkTapped: onStoryLinkTapped,
actionable: false, actionable: false,
fetchMode: FetchMode.eager, fetchMode: FetchMode.eager,
), ),

View File

@ -231,7 +231,6 @@ class _ProfileScreenState extends State<ProfileScreen>
authState: authState, authState: authState,
magicWord: Constants.magicWord, magicWord: Constants.magicWord,
pageType: pageType, pageType: pageType,
onLoginTapped: onLoginTapped,
), ),
Align( Align(
alignment: Alignment.topLeft, alignment: Alignment.topLeft,

View File

@ -32,13 +32,11 @@ class Settings extends StatefulWidget {
required this.authState, required this.authState,
required this.magicWord, required this.magicWord,
required this.pageType, required this.pageType,
required this.onLoginTapped,
}); });
final AuthState authState; final AuthState authState;
final String magicWord; final String magicWord;
final PageType pageType; final PageType pageType;
final VoidCallback onLoginTapped;
@override @override
State<Settings> createState() => _SettingsState(); State<Settings> createState() => _SettingsState();
@ -69,7 +67,7 @@ class _SettingsState extends State<Settings> {
if (widget.authState.isLoggedIn) { if (widget.authState.isLoggedIn) {
onLogoutTapped(); onLogoutTapped();
} else { } else {
widget.onLoginTapped(); onLoginTapped();
} }
}, },
), ),

View File

@ -226,10 +226,8 @@ class _SearchScreenState extends State<SearchScreen> {
else if (e is Comment) else if (e is Comment)
FadeIn( FadeIn(
child: CommentTile( child: CommentTile(
myUsername: '',
actionable: false, actionable: false,
comment: e, comment: e,
onStoryLinkTapped: (_) {},
fetchMode: FetchMode.eager, fetchMode: FetchMode.eager,
onTap: () => goToItemScreen( onTap: () => goToItemScreen(
args: ItemScreenArgs(item: e), args: ItemScreenArgs(item: e),

View File

@ -9,14 +9,11 @@ import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart'; import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/services/services.dart'; import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
class CommentTile extends StatelessWidget { class CommentTile extends StatelessWidget {
const CommentTile({ const CommentTile({
super.key, super.key,
required this.myUsername,
required this.comment, required this.comment,
required this.onStoryLinkTapped,
required this.fetchMode, required this.fetchMode,
this.onReplyTapped, this.onReplyTapped,
this.onMoreTapped, this.onMoreTapped,
@ -28,7 +25,6 @@ class CommentTile extends StatelessWidget {
this.onTap, this.onTap,
}); });
final String? myUsername;
final String? opUsername; final String? opUsername;
final Comment comment; final Comment comment;
final int level; final int level;
@ -39,7 +35,6 @@ class CommentTile extends StatelessWidget {
final void Function(Comment, Rect?)? onMoreTapped; final void Function(Comment, Rect?)? onMoreTapped;
final void Function(Comment)? onEditTapped; final void Function(Comment)? onEditTapped;
final void Function(Comment)? onRightMoreTapped; final void Function(Comment)? onRightMoreTapped;
final void Function(String) onStoryLinkTapped;
/// Override for search screen. /// Override for search screen.
final VoidCallback? onTap; final VoidCallback? onTap;
@ -193,11 +188,16 @@ class CommentTile extends StatelessWidget {
), ),
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
child: _CommentText( child: ItemText(
key: ValueKey<int>(comment.id), key: ValueKey<int>(comment.id),
comment: comment, item: comment,
onLinkTapped: _onLinkTapped, onTap: () {
onTap: onTap, if (onTap == null) {
_onTextTapped(context);
} else {
onTap!.call();
}
},
), ),
), ),
), ),
@ -250,7 +250,8 @@ class CommentTile extends StatelessWidget {
final Color commentColor = prefState.eyeCandyEnabled final Color commentColor = prefState.eyeCandyEnabled
? color.withOpacity(commentBackgroundColorOpacity) ? color.withOpacity(commentBackgroundColorOpacity)
: Palette.transparent; : Palette.transparent;
final bool isMyComment = myUsername == comment.by; final bool isMyComment = comment.deleted == false &&
context.read<AuthBloc>().state.username == comment.by;
Widget wrapper = child; Widget wrapper = child;
@ -333,83 +334,7 @@ class CommentTile extends StatelessWidget {
commentsState?.onlyShowTargetComment == false; commentsState?.onlyShowTargetComment == false;
} }
void _onLinkTapped(LinkableElement link) { void _onTextTapped(BuildContext context) {
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,
this.onTap,
});
final Comment comment;
final void Function(LinkableElement) onLinkTapped;
/// Override for search screen.
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 (comment is BuildableComment) {
return SelectableText.rich(
buildTextSpan(
(comment as BuildableComment).elements,
style: style,
linkStyle: linkStyle,
onOpen: onLinkTapped,
),
onTap: () {
if (onTap == null) {
onTextTapped(context);
} else {
onTap!.call();
}
},
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: () {
if (onTap == null) {
onTextTapped(context);
} else {
onTap!.call();
}
},
);
}
}
void onTextTapped(BuildContext context) {
if (context.read<PreferenceCubit>().state.tapAnywhereToCollapseEnabled) { if (context.read<PreferenceCubit>().state.tapAnywhereToCollapseEnabled) {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
context.read<CollapseCubit>().collapse(); 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/config/locator.dart';
import 'package:hacki/cubits/cubits.dart' show ReminderCubit, ReminderState; import 'package:hacki/cubits/cubits.dart' show ReminderCubit, ReminderState;
import 'package:hacki/extensions/extensions.dart'; 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/repositories/repositories.dart';
import 'package:hacki/screens/screens.dart'; import 'package:hacki/screens/screens.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';

View File

@ -181,6 +181,7 @@ class SelectableLinkify extends StatelessWidget {
this.cursorHeight, this.cursorHeight,
this.selectionControls, this.selectionControls,
this.onSelectionChanged, this.onSelectionChanged,
this.contextMenuBuilder = _defaultContextMenuBuilder,
}); });
/// Text to be linkified /// Text to be linkified
@ -273,6 +274,8 @@ class SelectableLinkify extends StatelessWidget {
/// cursor location). /// cursor location).
final SelectionChangedCallback? onSelectionChanged; final SelectionChangedCallback? onSelectionChanged;
final EditableTextContextMenuBuilder? contextMenuBuilder;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final List<LinkifyElement> elements = LinkifierUtil.linkify(text); final List<LinkifyElement> elements = LinkifierUtil.linkify(text);
@ -312,6 +315,16 @@ class SelectableLinkify extends StatelessWidget {
cursorHeight: cursorHeight, cursorHeight: cursorHeight,
selectionControls: selectionControls, selectionControls: selectionControls,
onSelectionChanged: onSelectionChanged, 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'; import 'package:linkify/linkify.dart';
final RegExp _emphasisRegex = RegExp( 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'; import 'package:linkify/linkify.dart';
final RegExp _quoteRegex = RegExp( 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_described_feature_overlay.dart';
export 'custom_linkify/custom_linkify.dart'; export 'custom_linkify/custom_linkify.dart';
export 'custom_tab_bar.dart'; export 'custom_tab_bar.dart';
export 'item_text.dart';
export 'items_list_view.dart'; export 'items_list_view.dart';
export 'link_preview/link_preview.dart'; export 'link_preview/link_preview.dart';
export 'offline_banner.dart'; export 'offline_banner.dart';

View File

@ -4,9 +4,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:hacki/config/constants.dart'; import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/main.dart'; import 'package:hacki/main.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart'; import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/screens.dart' show WebViewScreen; import 'package:hacki/screens/screens.dart'
show ItemScreen, ItemScreenArgs, WebViewScreen;
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@ -45,6 +48,11 @@ abstract class LinkUtil {
return; return;
} }
if (link.isStoryLink) {
_onStoryLinkTapped(link);
return;
}
Uri rinseLink(String link) { Uri rinseLink(String link) {
final RegExp regex = RegExp(RegExpConstants.linkSuffix); final RegExp regex = RegExp(RegExpConstants.linkSuffix);
if (!link.contains('en.wikipedia.org') && link.contains(regex)) { if (!link.contains('en.wikipedia.org') && link.contains(regex)) {
@ -80,4 +88,21 @@ 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),
);
}
});
}
}
} }

View File

@ -1,6 +1,6 @@
name: hacki name: hacki
description: A Hacker News reader. description: A Hacker News reader.
version: 1.2.0+93 version: 1.2.2+95
publish_to: none publish_to: none
environment: environment: