mirror of
https://github.com/Livinglist/Hacki.git
synced 2025-08-06 18:24:42 +08:00
Compare commits
8 Commits
Author | SHA1 | Date | |
---|---|---|---|
2af10391bc | |||
c420dd3ca4 | |||
da7d0757cd | |||
32ae2087bc | |||
0b5329d050 | |||
c375def289 | |||
3469543c7b | |||
ab755581fd |
18
assets/eula.md
Normal file
18
assets/eula.md
Normal file
@ -0,0 +1,18 @@
|
||||
## End-user License Agreement
|
||||
This policy applies to the usage of the Hacki app.
|
||||
|
||||
Please read this Mobile Application End User License Agreement (“EULA”) carefully before using the Hacki mobile application ("Mobile App"), which allows You to read and contribute to Hacker News from Your mobile device. This EULA forms a binding legal agreement between you (and any other entity on whose behalf you accept these terms) (collectively “You” or “Your”) and Hacki (each separately a “Party” and collectively the “Parties”) as of the date you download the Mobile App. Your use of the Mobile App is subject to this EULA.
|
||||
|
||||
### Changes to this EULA
|
||||
Hacki reserves the right to modify this EULA at any time and for any reason. You are responsible for complying with the updated EULA. Your continued use of the Mobile App indicates Your consent to the updated terms.
|
||||
|
||||
### No Included Maintenance and Support
|
||||
Hacki may deploy changes, updates, or enhancements to the Mobile App at any time. Hacki may provide maintenance and support for the Mobile App, but has no obligation whatsoever to furnish such services to You and may terminate such services at any time without notice.
|
||||
|
||||
### No Warranty
|
||||
Hacki expressly disclaims all warranties of any kind, whether express or implied.
|
||||
|
||||
The Mobile App is only available for supported devices and might not work on every device. Determining whether Your device is a supported or compatible device for use of the Mobile App is solely Your responsibility, and downloading the Mobile App is done at Your own risk. Smartsheet does not represent or warrant that the Mobile App and Your device are compatible or that the Mobile App will work on Your device.
|
||||
|
||||
### Your Consent
|
||||
By using the app, you consent to the end-user license agreement.
|
48
assets/privacy_policy.md
Normal file
48
assets/privacy_policy.md
Normal file
@ -0,0 +1,48 @@
|
||||
## Privacy Policy
|
||||
This policy applies to all information collected or submitted on Hacki.
|
||||
|
||||
### Information we collect
|
||||
Hacki collects anonymous statistics such as crash reports and feature usage. These data are solely used to track app's health and are only stored locally on your device and only got sent to us when you choose to do so.
|
||||
|
||||
### Ads and analytics
|
||||
Hacki does not serve ads.
|
||||
|
||||
Hacki collects aggregate, anonymous statistics to improve the app but these data are only stored locally on your device and only got sent to us when you choose to do so.
|
||||
|
||||
### Information usage
|
||||
We use the information we collect to operate and improve our website, apps, and customer support.
|
||||
|
||||
We do not share personal information with outside parties except to the extent necessary to accomplish Hacki’s functionality.
|
||||
|
||||
We may disclose your information in response to subpoenas, court orders, or other legal requirements; to exercise our legal rights or defend against legal claims; to investigate, prevent, or take action regarding illegal activities, suspected fraud or abuse, violations of our policies; or to protect our rights and property.
|
||||
|
||||
### Security
|
||||
Hacki uses the official Hacker News API for fetching data from Hacker News.
|
||||
|
||||
When logging in, usernames and passwords are securely sent to Hacker News' servers for authentication.
|
||||
|
||||
### Third-party links and content
|
||||
Hacki displays links and content from third-party websites. These websites have their own independent privacy policies, and we have no responsibility or liability for their content or activities.
|
||||
|
||||
#### California Online Privacy Protection Act Compliance
|
||||
Hacki complies with the California Online Privacy Protection Act. We therefore will not distribute your personal information to outside parties without your consent.
|
||||
|
||||
#### Children’s Online Privacy Protection Act Compliance
|
||||
Hacki never collects or maintain information at our website from those we actually know are under 13, and no part of our website is structured to attract anyone under 13.
|
||||
|
||||
#### Information for European Union Customers
|
||||
By using Hacki and providing your information, you authorize us to collect, use, and store your information outside of the European Union.
|
||||
|
||||
#### International Transfers of Information
|
||||
Information may be processed, stored, and used outside of the country in which you are located. Data privacy laws vary across jurisdictions, and different laws may be applicable to your data depending on where it is processed, stored, or used.
|
||||
|
||||
### Your Consent
|
||||
By using the app, you consent to the privacy policy.
|
||||
|
||||
### Contacting Us
|
||||
If you have questions regarding this privacy policy, you may e-mail me us at jfeng@fastmail.com.
|
||||
|
||||
### Changes to this policy
|
||||
If we decide to change this privacy policy, we will post those changes on this page.
|
||||
|
||||
February 27, 2023: First published.
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -2,7 +2,9 @@ import 'package:hacki/extensions/extensions.dart';
|
||||
|
||||
abstract class Constants {
|
||||
static const String endUserAgreementLink =
|
||||
'https://www.termsfeed.com/live/c1417f5c-a48b-4bd7-93b2-9cd4577bfc45';
|
||||
'https://github.com/Livinglist/Hacki/blob/master/assets/eula.md';
|
||||
static const String privacyPolicyLink =
|
||||
'https://github.com/Livinglist/Hacki/blob/master/assets/privacy_policy.md';
|
||||
static const String hackerNewsLogoLink =
|
||||
'https://pbs.twimg.com/profile_images/469397708986269696/iUrYEOpJ_400x400.png';
|
||||
static const String portfolioLink = 'https://livinglist.github.io';
|
||||
|
@ -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();
|
||||
|
@ -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();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
@ -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>[
|
||||
|
5
lib/models/item/buildable.dart
Normal file
5
lib/models/item/buildable.dart
Normal file
@ -0,0 +1,5 @@
|
||||
import 'package:hacki/screens/widgets/custom_linkify/custom_linkify.dart';
|
||||
|
||||
mixin Buildable {
|
||||
List<LinkifyElement> get elements;
|
||||
}
|
@ -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;
|
||||
}
|
46
lib/models/item/buildable_story.dart
Normal file
46
lib/models/item/buildable_story.dart
Normal 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;
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import 'package:hacki/models/item.dart';
|
||||
import 'package:hacki/models/item/item.dart';
|
||||
|
||||
class Comment extends Item {
|
||||
Comment({
|
@ -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 {
|
@ -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({
|
@ -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({
|
@ -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';
|
||||
|
@ -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(
|
||||
@ -497,7 +493,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
|
||||
size: size,
|
||||
deviceType: deviceType,
|
||||
widthFactor: widthFactor,
|
||||
onStoryLinkTapped: onStoryLinkTapped,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -1,6 +1,7 @@
|
||||
enum MenuAction {
|
||||
upvote,
|
||||
downvote,
|
||||
fav,
|
||||
share,
|
||||
block,
|
||||
flag,
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
@ -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';
|
||||
@ -16,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>(
|
||||
@ -69,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,
|
||||
@ -114,13 +125,8 @@ 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(
|
||||
@ -174,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(
|
||||
|
@ -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',
|
||||
);
|
||||
}
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -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) {
|
||||
@ -71,9 +68,6 @@ class TimeMachineDialog extends StatelessWidget {
|
||||
in state.ancestors) ...<Widget>[
|
||||
CommentTile(
|
||||
comment: c,
|
||||
myUsername:
|
||||
context.read<AuthBloc>().state.username,
|
||||
onStoryLinkTapped: onStoryLinkTapped,
|
||||
actionable: false,
|
||||
fetchMode: FetchMode.eager,
|
||||
),
|
||||
|
@ -231,7 +231,6 @@ class _ProfileScreenState extends State<ProfileScreen>
|
||||
authState: authState,
|
||||
magicWord: Constants.magicWord,
|
||||
pageType: pageType,
|
||||
onLoginTapped: onLoginTapped,
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.topLeft,
|
||||
|
@ -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();
|
||||
}
|
||||
},
|
||||
),
|
||||
@ -464,6 +462,22 @@ class _SettingsState extends State<Settings> {
|
||||
],
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => LinkUtil.launch(
|
||||
Constants.privacyPolicyLink,
|
||||
),
|
||||
child: Row(
|
||||
children: const <Widget>[
|
||||
Icon(
|
||||
Icons.privacy_tip_outlined,
|
||||
),
|
||||
SizedBox(
|
||||
width: Dimens.pt12,
|
||||
),
|
||||
Text('Privacy policy'),
|
||||
],
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: onReportIssueTapped,
|
||||
child: Row(
|
||||
|
@ -226,10 +226,8 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
else if (e is Comment)
|
||||
FadeIn(
|
||||
child: CommentTile(
|
||||
myUsername: '',
|
||||
actionable: false,
|
||||
comment: e,
|
||||
onStoryLinkTapped: (_) {},
|
||||
fetchMode: FetchMode.eager,
|
||||
onTap: () => goToItemScreen(
|
||||
args: ItemScreenArgs(item: e),
|
||||
|
@ -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,
|
||||
@ -28,7 +25,6 @@ class CommentTile extends StatelessWidget {
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final String? myUsername;
|
||||
final String? opUsername;
|
||||
final Comment comment;
|
||||
final int level;
|
||||
@ -39,7 +35,6 @@ class CommentTile extends StatelessWidget {
|
||||
final void Function(Comment, Rect?)? onMoreTapped;
|
||||
final void Function(Comment)? onEditTapped;
|
||||
final void Function(Comment)? onRightMoreTapped;
|
||||
final void Function(String) onStoryLinkTapped;
|
||||
|
||||
/// Override for search screen.
|
||||
final VoidCallback? onTap;
|
||||
@ -193,11 +188,16 @@ class CommentTile extends StatelessWidget {
|
||||
),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: _CommentText(
|
||||
child: ItemText(
|
||||
key: ValueKey<int>(comment.id),
|
||||
comment: comment,
|
||||
onLinkTapped: _onLinkTapped,
|
||||
onTap: onTap,
|
||||
item: comment,
|
||||
onTap: () {
|
||||
if (onTap == null) {
|
||||
_onTextTapped(context);
|
||||
} else {
|
||||
onTap!.call();
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -250,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;
|
||||
|
||||
@ -333,83 +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,
|
||||
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) {
|
||||
void _onTextTapped(BuildContext context) {
|
||||
if (context.read<PreferenceCubit>().state.tapAnywhereToCollapseEnabled) {
|
||||
HapticFeedback.selectionClick();
|
||||
context.read<CollapseCubit>().collapse();
|
||||
|
@ -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';
|
||||
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,4 +1,4 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:linkify/linkify.dart';
|
||||
|
||||
final RegExp _emphasisRegex = RegExp(
|
||||
|
@ -1,4 +1,4 @@
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:linkify/linkify.dart';
|
||||
|
||||
final RegExp _quoteRegex = RegExp(
|
||||
|
71
lib/screens/widgets/item_text.dart
Normal file
71
lib/screens/widgets/item_text.dart
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -1,10 +1,12 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:memoize/memoize.dart';
|
||||
|
||||
class LinkView extends StatelessWidget {
|
||||
const LinkView({
|
||||
LinkView({
|
||||
super.key,
|
||||
required this.metadata,
|
||||
required this.url,
|
||||
@ -13,18 +15,19 @@ class LinkView extends StatelessWidget {
|
||||
required this.description,
|
||||
required this.onTap,
|
||||
required this.showMetadata,
|
||||
required this.showUrl,
|
||||
required bool showUrl,
|
||||
required this.bodyMaxLines,
|
||||
this.imageUri,
|
||||
this.imagePath,
|
||||
this.titleTextStyle,
|
||||
this.bodyTextStyle,
|
||||
this.showMultiMedia = true,
|
||||
this.bodyTextOverflow,
|
||||
this.bodyMaxLines,
|
||||
this.isIcon = false,
|
||||
this.bgColor,
|
||||
this.radius = 0,
|
||||
}) : assert(
|
||||
}) : showUrl = showUrl && url.isNotEmpty,
|
||||
assert(
|
||||
!showMultiMedia ||
|
||||
(showMultiMedia && (imageUri != null || imagePath != null)),
|
||||
'imageUri or imagePath cannot be null when showMultiMedia is true',
|
||||
@ -42,14 +45,17 @@ class LinkView extends StatelessWidget {
|
||||
final TextStyle? bodyTextStyle;
|
||||
final bool showMultiMedia;
|
||||
final TextOverflow? bodyTextOverflow;
|
||||
final int? bodyMaxLines;
|
||||
final int bodyMaxLines;
|
||||
final bool isIcon;
|
||||
final double radius;
|
||||
final Color? bgColor;
|
||||
final bool showMetadata;
|
||||
final bool showUrl;
|
||||
|
||||
double computeTitleFontSize(double width) {
|
||||
static final double Function(double) _getTitleFontSize =
|
||||
memo1(_computeTitleFontSize);
|
||||
|
||||
static double _computeTitleFontSize(double width) {
|
||||
double size = width * 0.13;
|
||||
if (size > 15) {
|
||||
size = 15;
|
||||
@ -57,16 +63,26 @@ class LinkView extends StatelessWidget {
|
||||
return size;
|
||||
}
|
||||
|
||||
int computeTitleLines(double layoutHeight) {
|
||||
static final int Function(double) _getTitleLines = memo1(_computeTitleLines);
|
||||
|
||||
static int _computeTitleLines(double layoutHeight) {
|
||||
return layoutHeight >= 100 ? 2 : 1;
|
||||
}
|
||||
|
||||
int computeBodyLines(double layoutHeight) {
|
||||
int lines = 1;
|
||||
if (layoutHeight > 40) {
|
||||
lines += (layoutHeight - 40.0) ~/ 15.0;
|
||||
}
|
||||
return lines;
|
||||
static final int Function(int, bool, bool, String?) _getBodyLines =
|
||||
memo4(_computeBodyLines);
|
||||
|
||||
static int _computeBodyLines(
|
||||
int bodyMaxLines,
|
||||
bool showMetadata,
|
||||
bool showUrl,
|
||||
String? fontFamily,
|
||||
) {
|
||||
final int maxLines = bodyMaxLines -
|
||||
(showMetadata ? 1 : 0) -
|
||||
(showUrl ? 1 : 0) +
|
||||
(fontFamily == Font.ubuntuMono.name ? 1 : 0);
|
||||
return maxLines;
|
||||
}
|
||||
|
||||
@override
|
||||
@ -76,15 +92,15 @@ class LinkView extends StatelessWidget {
|
||||
final double layoutWidth = constraints.biggest.width;
|
||||
final double layoutHeight = constraints.biggest.height;
|
||||
|
||||
final TextStyle titleFontSize = titleTextStyle ??
|
||||
final TextStyle titleFontStyle = titleTextStyle ??
|
||||
TextStyle(
|
||||
fontSize: computeTitleFontSize(layoutWidth),
|
||||
fontSize: _getTitleFontSize(layoutWidth),
|
||||
color: Palette.black,
|
||||
fontWeight: FontWeight.bold,
|
||||
);
|
||||
final TextStyle bodyFontSize = bodyTextStyle ??
|
||||
final TextStyle bodyFontStyle = bodyTextStyle ??
|
||||
TextStyle(
|
||||
fontSize: computeTitleFontSize(layoutWidth) - 1,
|
||||
fontSize: _getTitleFontSize(layoutWidth) - 1,
|
||||
color: Palette.grey,
|
||||
fontWeight: FontWeight.w400,
|
||||
);
|
||||
@ -96,7 +112,7 @@ class LinkView extends StatelessWidget {
|
||||
if (showMultiMedia)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: 5,
|
||||
right: 8,
|
||||
top: 5,
|
||||
bottom: 5,
|
||||
),
|
||||
@ -112,7 +128,7 @@ class LinkView extends StatelessWidget {
|
||||
imageUrl: imageUri!,
|
||||
fit: isIcon ? BoxFit.scaleDown : BoxFit.fitWidth,
|
||||
memCacheHeight: layoutHeight.toInt() * 4,
|
||||
errorWidget: (BuildContext context, _, dynamic __) {
|
||||
errorWidget: (BuildContext context, _, __) {
|
||||
return Image.asset(
|
||||
Constants.hackerNewsLogoPath,
|
||||
fit: BoxFit.cover,
|
||||
@ -124,22 +140,85 @@ class LinkView extends StatelessWidget {
|
||||
else
|
||||
const SizedBox(width: 5),
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
_buildTitleContainer(
|
||||
titleFontSize,
|
||||
computeTitleLines(layoutHeight),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyMedium
|
||||
?.fontFamily ==
|
||||
Font.robotoSlab.name
|
||||
? 2
|
||||
: 4,
|
||||
),
|
||||
_buildBodyContainer(
|
||||
bodyFontSize,
|
||||
computeBodyLines(layoutHeight),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Text(
|
||||
title,
|
||||
style: titleFontStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: _getTitleLines(layoutHeight),
|
||||
),
|
||||
),
|
||||
if (showUrl && url.isNotEmpty)
|
||||
Container(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Text(
|
||||
'($readableUrl)',
|
||||
textAlign: TextAlign.left,
|
||||
style: titleFontStyle.copyWith(
|
||||
color: Palette.grey,
|
||||
fontSize: titleFontStyle.fontSize == null
|
||||
? 12
|
||||
: titleFontStyle.fontSize! - 4,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
overflow:
|
||||
bodyTextOverflow ?? TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showMetadata)
|
||||
Container(
|
||||
alignment: Alignment.topLeft,
|
||||
margin: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
metadata,
|
||||
textAlign: TextAlign.left,
|
||||
style: bodyFontStyle.copyWith(
|
||||
fontSize: bodyFontStyle.fontSize == null
|
||||
? 12
|
||||
: bodyFontStyle.fontSize! - 2,
|
||||
),
|
||||
overflow: bodyTextOverflow ?? TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Text(
|
||||
description,
|
||||
textAlign: TextAlign.left,
|
||||
style: bodyFontStyle,
|
||||
overflow: bodyTextOverflow ?? TextOverflow.ellipsis,
|
||||
maxLines: _getBodyLines(
|
||||
bodyMaxLines,
|
||||
showMetadata,
|
||||
showUrl,
|
||||
Theme.of(context).textTheme.bodyMedium?.fontFamily,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@ -148,81 +227,4 @@ class LinkView extends StatelessWidget {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTitleContainer(TextStyle titleTS, int maxLines) {
|
||||
final bool showUrl = this.showUrl && url.isNotEmpty;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 2, 3, 0),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Container(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Text(
|
||||
title,
|
||||
style: titleTS,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: maxLines,
|
||||
),
|
||||
),
|
||||
if (showUrl)
|
||||
Container(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Text(
|
||||
'($readableUrl)',
|
||||
textAlign: TextAlign.left,
|
||||
style: titleTS.copyWith(
|
||||
color: Palette.grey,
|
||||
fontSize:
|
||||
titleTS.fontSize == null ? 12 : titleTS.fontSize! - 4,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
overflow: bodyTextOverflow ?? TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBodyContainer(TextStyle bodyTS, int maxLines) {
|
||||
return Expanded(
|
||||
flex: 2,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(5, 2, 5, 0),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
if (showMetadata)
|
||||
Container(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Text(
|
||||
metadata,
|
||||
textAlign: TextAlign.left,
|
||||
style: bodyTS.copyWith(
|
||||
fontSize:
|
||||
bodyTS.fontSize == null ? 12 : bodyTS.fontSize! - 2,
|
||||
),
|
||||
overflow: bodyTextOverflow ?? TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Container(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Text(
|
||||
description,
|
||||
textAlign: TextAlign.left,
|
||||
style: bodyTS,
|
||||
overflow: bodyTextOverflow ?? TextOverflow.ellipsis,
|
||||
maxLines: (bodyMaxLines ?? maxLines) -
|
||||
(showMetadata ? 1 : 0) -
|
||||
(showUrl && url.isNotEmpty ? 1 : 0),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -600,6 +600,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
memoize:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: memoize
|
||||
sha256: "51481d328c86cbdc59711369179bac88551ca0556569249be5317e66fc796cac"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -1,6 +1,6 @@
|
||||
name: hacki
|
||||
description: A Hacker News reader.
|
||||
version: 1.2.0+93
|
||||
version: 1.2.5+98
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
@ -45,6 +45,7 @@ dependencies:
|
||||
intl: ^0.18.0
|
||||
linkify: ^4.1.0
|
||||
logger: ^1.1.0
|
||||
memoize: ^3.0.0
|
||||
package_info_plus: ^3.0.3
|
||||
path: ^1.8.2
|
||||
path_provider: ^2.0.12
|
||||
|
Reference in New Issue
Block a user