Compare commits

...

10 Commits

Author SHA1 Message Date
0ca3e96d91 update story tile. (#183) 2023-03-02 18:36:23 -08:00
d1c8eed3de bump flutter version. (#182) 2023-03-02 00:29:43 -08:00
aa6a2c684c bugfixes. (#181) 2023-03-01 12:24:16 -08:00
d4778d9530 remove bottom padding. (#178) 2023-02-28 15:29:03 -08:00
c702e08481 allow exporting favorites to clipboard. (#177) 2023-02-28 14:54:51 -08:00
2af10391bc update story tile. (#175) 2023-02-27 16:48:53 -08:00
c420dd3ca4 correct spelling. (#174) 2023-02-27 15:16:10 -08:00
da7d0757cd add link to privacy policy. (#173) 2023-02-27 14:48:49 -08:00
32ae2087bc fix link button. (#171) 2023-02-26 23:03:48 -08:00
0b5329d050 bugfixes. (#170) 2023-02-26 15:08:18 -08:00
48 changed files with 1010 additions and 690 deletions

View File

@ -50,7 +50,7 @@ android {
defaultConfig {
applicationId "com.jiaqifeng.hacki"
minSdkVersion 26
minSdkVersion 30
targetSdkVersion 33
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
@ -64,12 +64,15 @@ android {
storePassword keystoreProperties['storePassword']
}
}
buildTypes {
release {
signingConfig signingConfigs.release
minifyEnabled true
shrinkResources true
}
}
}
flutter {

View File

@ -37,15 +37,6 @@
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<!-- Displays an Android View that continues showing the launch screen
Drawable until Flutter paints its first frame, then this splash
screen fades out. A splash screen is useful to avoid any visual
gap between the end of Android's launch screen and the painting of
Flutter's first frame. -->
<meta-data
android:name="io.flutter.embedding.android.SplashScreenDrawable"
android:resource="@drawable/launch_background"
/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>

18
assets/eula.md Normal file
View 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
View 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 Hackis 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.
#### Childrens 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.

View File

@ -74,7 +74,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
final int pageSize = getPageSize(isComplexTile: isComplexTile);
emit(
const StoriesState.init().copyWith(
offlineReading: hasCachedStories &&
isOfflineReading: hasCachedStories &&
// Only go into offline mode in the next session.
state.downloadStatus == StoriesDownloadStatus.initial,
currentPageSize: pageSize,
@ -92,7 +92,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
required StoryType type,
required Emitter<StoriesState> emit,
}) async {
if (state.offlineReading) {
if (state.isOfflineReading) {
final List<int> ids =
await _offlineRepository.getCachedStoryIds(type: type);
emit(
@ -137,7 +137,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
),
);
if (state.offlineReading) {
if (state.isOfflineReading) {
emit(
state.copyWithStatusUpdated(
type: event.type,
@ -172,7 +172,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
upper = len;
}
if (state.offlineReading) {
if (state.isOfflineReading) {
_offlineRepository
.getCachedStoriesStream(
ids: state.storyIdsByType[event.type]!.sublist(
@ -440,7 +440,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
await _offlineRepository.deleteAllStories();
await _offlineRepository.deleteAllComments();
await _offlineRepository.deleteAllWebPages();
emit(state.copyWith(offlineReading: false));
emit(state.copyWith(isOfflineReading: false));
add(StoriesInitialize());
}

View File

@ -21,7 +21,7 @@ class StoriesState extends Equatable {
required this.statusByType,
required this.currentPageByType,
required this.readStoriesIds,
required this.offlineReading,
required this.isOfflineReading,
required this.downloadStatus,
required this.currentPageSize,
required this.storiesDownloaded,
@ -57,7 +57,7 @@ class StoriesState extends Equatable {
StoryType.ask: 0,
StoryType.show: 0,
},
}) : offlineReading = false,
}) : isOfflineReading = false,
downloadStatus = StoriesDownloadStatus.initial,
currentPageSize = 0,
readStoriesIds = const <int>{},
@ -70,7 +70,7 @@ class StoriesState extends Equatable {
final Map<StoryType, int> currentPageByType;
final Set<int> readStoriesIds;
final StoriesDownloadStatus downloadStatus;
final bool offlineReading;
final bool isOfflineReading;
final int currentPageSize;
final int storiesDownloaded;
final int storiesToBeDownloaded;
@ -82,7 +82,7 @@ class StoriesState extends Equatable {
Map<StoryType, int>? currentPageByType,
Set<int>? readStoriesIds,
StoriesDownloadStatus? downloadStatus,
bool? offlineReading,
bool? isOfflineReading,
int? currentPageSize,
int? storiesDownloaded,
int? storiesToBeDownloaded,
@ -93,7 +93,7 @@ class StoriesState extends Equatable {
statusByType: statusByType ?? this.statusByType,
currentPageByType: currentPageByType ?? this.currentPageByType,
readStoriesIds: readStoriesIds ?? this.readStoriesIds,
offlineReading: offlineReading ?? this.offlineReading,
isOfflineReading: isOfflineReading ?? this.isOfflineReading,
downloadStatus: downloadStatus ?? this.downloadStatus,
currentPageSize: currentPageSize ?? this.currentPageSize,
storiesDownloaded: storiesDownloaded ?? this.storiesDownloaded,
@ -183,7 +183,7 @@ class StoriesState extends Equatable {
statusByType,
currentPageByType,
readStoriesIds,
offlineReading,
isOfflineReading,
downloadStatus,
currentPageSize,
storiesDownloaded,

View File

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

View File

@ -26,7 +26,8 @@ class CommentsCubit extends Cubit<CommentsState> {
StoriesRepository? storiesRepository,
SembastRepository? sembastRepository,
Logger? logger,
required bool offlineReading,
required bool isScreenReaderEnabled,
required bool isOfflineReading,
required Item item,
required FetchMode defaultFetchMode,
required CommentsOrder defaultCommentsOrder,
@ -39,9 +40,10 @@ class CommentsCubit extends Cubit<CommentsState> {
_sembastRepository =
sembastRepository ?? locator.get<SembastRepository>(),
_logger = logger ?? locator.get<Logger>(),
_isScreenReaderEnabled = isScreenReaderEnabled,
super(
CommentsState.init(
offlineReading: offlineReading,
isOfflineReading: isOfflineReading,
item: item,
fetchMode: defaultFetchMode,
order: defaultCommentsOrder,
@ -54,6 +56,7 @@ class CommentsCubit extends Cubit<CommentsState> {
final StoriesRepository _storiesRepository;
final SembastRepository _sembastRepository;
final Logger _logger;
final bool _isScreenReaderEnabled;
/// The [StreamSubscription] for stream (both lazy or eager)
/// fetching comments posted directly to the story.
@ -109,7 +112,7 @@ class CommentsCubit extends Cubit<CommentsState> {
);
final Item item = state.item;
final Item updatedItem = state.offlineReading
final Item updatedItem = state.isOfflineReading
? item
: await _storiesRepository.fetchItem(id: item.id).then(_toBuildable) ??
item;
@ -119,7 +122,7 @@ class CommentsCubit extends Cubit<CommentsState> {
late final Stream<Comment> commentStream;
if (state.offlineReading) {
if (state.isOfflineReading) {
commentStream = _offlineRepository.getCachedCommentsStream(ids: kids);
} else {
switch (state.fetchMode) {
@ -152,7 +155,7 @@ class CommentsCubit extends Cubit<CommentsState> {
),
);
if (state.offlineReading) {
if (state.isOfflineReading) {
emit(
state.copyWith(
status: CommentsStatus.allLoaded,
@ -356,6 +359,9 @@ class CommentsCubit extends Cubit<CommentsState> {
emit(state.copyWith(comments: updatedComments));
if (state.fetchMode == FetchMode.eager) {
/// If screen reader is on, fetch all the comments without paging.
if (_isScreenReaderEnabled) return;
if (updatedComments.length >=
_pageSize + _pageSize * state.currentPage &&
updatedComments.length <=

View File

@ -17,12 +17,12 @@ class CommentsState extends Equatable {
required this.order,
required this.fetchMode,
required this.onlyShowTargetComment,
required this.offlineReading,
required this.isOfflineReading,
required this.currentPage,
});
CommentsState.init({
required this.offlineReading,
required this.isOfflineReading,
required this.item,
required this.fetchMode,
required this.order,
@ -39,7 +39,7 @@ class CommentsState extends Equatable {
final CommentsOrder order;
final FetchMode fetchMode;
final bool onlyShowTargetComment;
final bool offlineReading;
final bool isOfflineReading;
final int currentPage;
CommentsState copyWith({
@ -50,7 +50,7 @@ class CommentsState extends Equatable {
CommentsOrder? order,
FetchMode? fetchMode,
bool? onlyShowTargetComment,
bool? offlineReading,
bool? isOfflineReading,
int? currentPage,
}) {
return CommentsState(
@ -62,7 +62,7 @@ class CommentsState extends Equatable {
fetchMode: fetchMode ?? this.fetchMode,
onlyShowTargetComment:
onlyShowTargetComment ?? this.onlyShowTargetComment,
offlineReading: offlineReading ?? this.offlineReading,
isOfflineReading: isOfflineReading ?? this.isOfflineReading,
currentPage: currentPage ?? this.currentPage,
);
}
@ -77,7 +77,7 @@ class CommentsState extends Equatable {
order,
fetchMode,
onlyShowTargetComment,
offlineReading,
isOfflineReading,
currentPage,
comments,
];

View File

@ -160,6 +160,13 @@ class FavCubit extends Cubit<FavState> {
});
}
void removeAll() {
_preferenceRepository
..clearAllFavs(username: '')
..clearAllFavs(username: _authBloc.state.username);
emit(FavState.init());
}
void _onItemLoaded(Item item) {
emit(
state.copyWith(

View File

@ -6,6 +6,8 @@ import 'package:hacki/config/constants.dart';
import 'package:hacki/styles/styles.dart';
extension ContextExtension on BuildContext {
bool get isScreenReaderEnabled => MediaQuery.of(this).accessibleNavigation;
T? tryRead<T>() {
try {
return read<T>();
@ -19,6 +21,8 @@ extension ContextExtension on BuildContext {
VoidCallback? action,
String? label,
}) {
if (isScreenReaderEnabled) return;
ScaffoldMessenger.of(this).showSnackBar(
SnackBar(
backgroundColor: Palette.deepOrange,

View File

@ -1,5 +1,5 @@
extension DateTimeExtension on DateTime {
String toReadableString() {
String toTimeAgoString() {
final DateTime now = DateTime.now();
final Duration diff = now.difference(this);
if (diff.inDays > 365) {

View File

@ -2,7 +2,18 @@ import 'package:hacki/config/locator.dart';
import 'package:logger/logger.dart';
extension ObjectExtension on Object {
void log({String identifier = ''}) {
void log([String identifier = '']) {
locator.get<Logger>().d('$identifier ${toString()}');
}
void logInfo({String identifier = ''}) {
locator.get<Logger>().i('$identifier ${toString()}');
}
void logError({
String identifier = '',
StackTrace? stackTrace,
}) {
locator.get<Logger>().e(identifier, this, stackTrace ?? StackTrace.current);
}
}

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/custom_linkify/linkifiers/linkifiers.dart';
import 'package:hacki/screens/widgets/custom_linkify/custom_linkify.dart';
import 'package:hacki/utils/utils.dart';
extension WidgetModifier on Widget {

View File

@ -24,7 +24,7 @@ class Comment extends Item {
final int level;
String get metadata => '''by $by $postedDate''';
String get metadata => '''by $by $timeAgo''';
Comment copyWith({int? level}) {
return Comment(

View File

@ -82,8 +82,8 @@ class Item extends Equatable {
final List<int> kids;
final List<int> parts;
String get postedDate =>
DateTime.fromMillisecondsSinceEpoch(time * 1000).toReadableString();
String get timeAgo =>
DateTime.fromMillisecondsSinceEpoch(time * 1000).toTimeAgoString();
bool get isPoll => type == 'poll';

View File

@ -43,10 +43,13 @@ class Story extends Item {
Story.fromJson(super.json) : super.fromJson();
String get metadata =>
'''$score point${score > 1 ? 's' : ''} by $by $postedDate | $descendants comment${descendants > 1 ? 's' : ''}''';
'''$score point${score > 1 ? 's' : ''} by $by $timeAgo | $descendants comment${descendants > 1 ? 's' : ''}''';
String get screenReaderLabel =>
'''$title, at $readableUrl, by $by $timeAgo. This story has $score point${score > 1 ? 's' : ''} and $descendants comment${descendants > 1 ? 's' : ''}''';
String get simpleMetadata =>
'''$score point${score > 1 ? 's' : ''} $descendants comment${descendants > 1 ? 's' : ''} $postedDate''';
'''$score point${score > 1 ? 's' : ''} $descendants comment${descendants > 1 ? 's' : ''} $timeAgo''';
String get readableUrl {
final Uri url = Uri.parse(this.url);
@ -55,10 +58,5 @@ class Story extends Item {
}
@override
String toString() {
// final String prettyString =
// const JsonEncoder.withIndent(' ').convert(this);
// return 'Story $prettyString';
return 'Story $id';
}
String toString() => 'Story $id';
}

View File

@ -31,7 +31,7 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
const NotificationModePreference(),
const SwipeGesturePreference(),
const CollapseModePreference(),
NavigationModePreference(),
const NavigationModePreference(),
const ReaderModePreference(),
const MarkReadStoriesModePreference(),
const EyeCandyModePreference(),
@ -54,8 +54,7 @@ abstract class IntPreference extends Preference<int> {
const bool _notificationModeDefaultValue = true;
const bool _swipeGestureModeDefaultValue = false;
const bool _displayModeDefaultValue = true;
const bool _navigationModeDefaultValueIOS = false;
const bool _navigationModeDefaultValueAndroid = false;
const bool _navigationModeDefaultValue = false;
const bool _eyeCandyModeDefaultValue = false;
const bool _trueDarkModeDefaultValue = false;
const bool _readerModeDefaultValue = true;
@ -193,12 +192,9 @@ class StoryUrlModePreference extends BooleanPreference {
/// The value deciding whether or not user should be
/// navigated to web view first. Defaults to false.
class NavigationModePreference extends BooleanPreference {
NavigationModePreference({bool? val})
const NavigationModePreference({bool? val})
: super(
val: val ??
(Platform.isAndroid
? _navigationModeDefaultValueAndroid
: _navigationModeDefaultValueIOS),
val: val ?? _navigationModeDefaultValue,
);
@override

View File

@ -207,6 +207,23 @@ class PreferenceRepository {
}
}
Future<void> clearAllFavs({required String username}) async {
final String key = _getFavKey(username);
if (Platform.isIOS) {
await _syncedPrefs.setStringList(
key: key,
val: <String>[],
);
} else {
final SharedPreferences prefs = await _prefs;
await prefs.setStringList(
key,
<String>[],
);
}
}
static String _getFavKey(String username) => 'fav_$username';
//#endregion

View File

@ -92,12 +92,14 @@ class _HomeScreenState extends State<HomeScreen>
SchedulerBinding.instance
..addPostFrameCallback((_) {
FeatureDiscovery.discoverFeatures(
context,
<String>{
Constants.featureLogIn,
},
);
if (context.isScreenReaderEnabled == false) {
FeatureDiscovery.discoverFeatures(
context,
<String>{
Constants.featureLogIn,
},
);
}
})
..addPostFrameCallback((_) {
final ModalRoute<dynamic>? route = ModalRoute.of(context);
@ -214,7 +216,7 @@ class _HomeScreenState extends State<HomeScreen>
context.read<PreferenceCubit>().state.webFirstEnabled;
final bool useReader = context.read<PreferenceCubit>().state.readerEnabled;
final bool offlineReading =
context.read<StoriesBloc>().state.offlineReading;
context.read<StoriesBloc>().state.isOfflineReading;
final bool hasRead = isPin || context.read<StoriesBloc>().hasRead(story);
final bool splitViewEnabled = context.read<SplitViewCubit>().state.enabled;
@ -225,7 +227,10 @@ class _HomeScreenState extends State<HomeScreen>
if (isJobWithLink) {
context.read<ReminderCubit>().removeLastReadStoryId();
} else {
final ItemScreenArgs args = ItemScreenArgs(item: story);
final ItemScreenArgs args = ItemScreenArgs(
item: story,
isScreenReaderEnabled: context.isScreenReaderEnabled,
);
context.read<ReminderCubit>().updateLastReadStoryId(story.id);

View File

@ -23,11 +23,13 @@ class ItemScreenArgs extends Equatable {
required this.item,
this.onlyShowTargetComment = false,
this.useCommentCache = false,
this.isScreenReaderEnabled = false,
this.targetComments,
});
final Item item;
final bool onlyShowTargetComment;
final bool isScreenReaderEnabled;
final List<Comment>? targetComments;
/// when a user is trying to view a sub-thread from a main thread, we don't
@ -39,6 +41,7 @@ class ItemScreenArgs extends Equatable {
List<Object?> get props => <Object?>[
item,
onlyShowTargetComment,
isScreenReaderEnabled,
targetComments,
useCommentCache,
];
@ -58,20 +61,21 @@ class ItemScreen extends StatefulWidget {
return MaterialPageRoute<ItemScreen>(
settings: const RouteSettings(name: routeName),
builder: (BuildContext context) => RepositoryProvider<CollapseCache>(
create: (BuildContext context) => CollapseCache(),
create: (_) => CollapseCache(),
lazy: false,
child: MultiBlocProvider(
providers: <BlocProvider<dynamic>>[
BlocProvider<CommentsCubit>(
create: (BuildContext context) => CommentsCubit(
offlineReading:
context.read<StoriesBloc>().state.offlineReading,
isOfflineReading:
context.read<StoriesBloc>().state.isOfflineReading,
item: args.item,
collapseCache: context.read<CollapseCache>(),
defaultFetchMode:
context.read<PreferenceCubit>().state.fetchMode,
defaultCommentsOrder:
context.read<PreferenceCubit>().state.order,
isScreenReaderEnabled: args.isScreenReaderEnabled,
)..init(
onlyShowTargetComment: args.onlyShowTargetComment,
targetAncestors: args.targetComments,
@ -99,21 +103,22 @@ class ItemScreen extends StatefulWidget {
}
},
child: RepositoryProvider<CollapseCache>(
create: (BuildContext context) => CollapseCache(),
create: (_) => CollapseCache(),
lazy: false,
child: MultiBlocProvider(
key: ValueKey<ItemScreenArgs>(args),
providers: <BlocProvider<dynamic>>[
BlocProvider<CommentsCubit>(
create: (BuildContext context) => CommentsCubit(
offlineReading:
context.read<StoriesBloc>().state.offlineReading,
isOfflineReading:
context.read<StoriesBloc>().state.isOfflineReading,
item: args.item,
collapseCache: context.read<CollapseCache>(),
defaultFetchMode:
context.read<PreferenceCubit>().state.fetchMode,
defaultCommentsOrder:
context.read<PreferenceCubit>().state.order,
isScreenReaderEnabled: args.isScreenReaderEnabled,
)..init(
onlyShowTargetComment: args.onlyShowTargetComment,
targetAncestors: args.targetComments,
@ -173,14 +178,16 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
SchedulerBinding.instance
..addPostFrameCallback((_) {
FeatureDiscovery.discoverFeatures(
context,
<String>{
Constants.featurePinToTop,
Constants.featureAddStoryToFavList,
Constants.featureOpenStoryInWebView,
},
);
if (context.isScreenReaderEnabled == false) {
FeatureDiscovery.discoverFeatures(
context,
<String>{
Constants.featurePinToTop,
Constants.featureAddStoryToFavList,
Constants.featureOpenStoryInWebView,
},
);
}
})
..addPostFrameCallback((_) {
final ModalRoute<dynamic>? route = ModalRoute.of(context);
@ -318,6 +325,8 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
context.read<SplitViewCubit>().zoom,
onFontSizeTap: onFontSizeTapped,
fontSizeIconButtonKey: fontSizeIconButtonKey,
isScreenReaderEnabled:
context.isScreenReaderEnabled,
),
);
},
@ -353,6 +362,7 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
scrollController: scrollController,
onFontSizeTap: onFontSizeTapped,
fontSizeIconButtonKey: fontSizeIconButtonKey,
isScreenReaderEnabled: context.isScreenReaderEnabled,
),
body: MainView(
scrollController: scrollController,

View File

@ -16,6 +16,7 @@ class CustomAppBar extends AppBar {
bool splitViewEnabled = false,
VoidCallback? onZoomTap,
bool? expanded,
bool isScreenReaderEnabled = false,
}) : super(
elevation: Dimens.zero,
actions: <Widget>[
@ -37,19 +38,20 @@ class CustomAppBar extends AppBar {
ScrollUpIconButton(
scrollController: scrollController,
),
IconButton(
key: fontSizeIconButtonKey,
icon: Text(
String.fromCharCode(FeatherIcons.type.codePoint),
style: TextStyle(
fontWeight: FontWeight.w800,
fontSize: TextDimens.pt18,
fontFamily: FeatherIcons.type.fontFamily,
package: FeatherIcons.type.fontPackage,
if (isScreenReaderEnabled == false)
IconButton(
key: fontSizeIconButtonKey,
icon: Text(
String.fromCharCode(FeatherIcons.type.codePoint),
style: TextStyle(
fontWeight: FontWeight.w800,
fontSize: TextDimens.pt18,
fontFamily: FeatherIcons.type.fontFamily,
package: FeatherIcons.type.fontPackage,
),
),
onPressed: onFontSizeTap,
),
onPressed: onFontSizeTap,
),
if (item is Story)
PinIconButton(
story: item,

View File

@ -24,17 +24,17 @@ class LinkIconButton extends StatelessWidget {
featureId: Constants.featureOpenStoryInWebView,
title: Text('Open in Browser'),
description: Text(
'Want more than just reading and replying? '
'You can tap here to open this story in a '
'browser.',
'''You can tap here to open this story in browser.''',
style: TextStyle(fontSize: TextDimens.pt16),
),
child: Icon(
Icons.stream,
),
),
onPressed: () =>
LinkUtil.launch('https://news.ycombinator.com/item?id=$storyId'),
onPressed: () => LinkUtil.launch(
'https://news.ycombinator.com/item?id=$storyId',
useHackiForHnLink: false,
),
);
}
}

View File

@ -87,7 +87,7 @@ class MainView extends StatelessWidget {
onRefresh: () {
HapticFeedback.lightImpact();
if (context.read<StoriesBloc>().state.offlineReading) {
if (context.read<StoriesBloc>().state.isOfflineReading) {
refreshController.refreshCompleted();
} else {
context.read<CommentsCubit>().refresh();
@ -231,232 +231,257 @@ class _ParentItemSection extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
SizedBox(
height: topPadding,
),
if (!splitViewEnabled)
const Padding(
padding: EdgeInsets.only(bottom: Dimens.pt6),
child: OfflineBanner(),
return Semantics(
label:
'''Posted by ${state.item.by} ${state.item.timeAgo}, ${state.item.title}. ${state.item.text}''',
child: Column(
children: <Widget>[
SizedBox(
height: topPadding,
),
Slidable(
startActionPane: ActionPane(
motion: const BehindMotion(),
children: <Widget>[
SlidableAction(
onPressed: (_) {
HapticFeedback.lightImpact();
if (!splitViewEnabled)
const Padding(
padding: EdgeInsets.only(bottom: Dimens.pt6),
child: OfflineBanner(),
),
Slidable(
startActionPane: ActionPane(
motion: const BehindMotion(),
children: <Widget>[
SlidableAction(
onPressed: (_) {
HapticFeedback.lightImpact();
if (state.item.id !=
context.read<EditCubit>().state.replyingTo?.id) {
commentEditingController.clear();
}
context.read<EditCubit>().onReplyTapped(state.item);
focusNode.requestFocus();
},
backgroundColor: Palette.orange,
foregroundColor: Palette.white,
icon: Icons.message,
),
SlidableAction(
onPressed: (BuildContext context) =>
onMoreTapped(state.item, context.rect),
backgroundColor: Palette.orange,
foregroundColor: Palette.white,
icon: Icons.more_horiz,
),
],
),
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(
left: Dimens.pt6,
right: Dimens.pt6,
if (state.item.id !=
context.read<EditCubit>().state.replyingTo?.id) {
commentEditingController.clear();
}
context.read<EditCubit>().onReplyTapped(state.item);
focusNode.requestFocus();
},
backgroundColor: Palette.orange,
foregroundColor: Palette.white,
icon: Icons.message,
),
child: Row(
children: <Widget>[
Text(
state.item.by,
style: const TextStyle(
color: Palette.orange,
),
),
const Spacer(),
Text(
state.item.postedDate,
style: const TextStyle(
color: Palette.grey,
),
),
],
SlidableAction(
onPressed: (BuildContext context) =>
onMoreTapped(state.item, context.rect),
backgroundColor: Palette.orange,
foregroundColor: Palette.white,
icon: Icons.more_horiz,
),
),
BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (
PreferenceState previous,
PreferenceState current,
) =>
previous.fontSize != current.fontSize,
builder: (
BuildContext context,
PreferenceState prefState,
) {
return Column(
],
),
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(
left: Dimens.pt6,
right: Dimens.pt6,
),
child: Row(
children: <Widget>[
if (state.item is Story)
InkWell(
onTap: () => LinkUtil.launch(
state.item.url,
useReader: context
.read<PreferenceCubit>()
.state
.readerEnabled,
offlineReading: context
.read<StoriesBloc>()
.state
.offlineReading,
),
child: Padding(
padding: const EdgeInsets.only(
left: Dimens.pt6,
right: Dimens.pt6,
bottom: Dimens.pt12,
top: Dimens.pt12,
Text(
state.item.by,
style: const TextStyle(
color: Palette.orange,
),
),
const Spacer(),
Text(
state.item.timeAgo,
style: const TextStyle(
color: Palette.grey,
),
),
],
),
),
BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (
PreferenceState previous,
PreferenceState current,
) =>
previous.fontSize != current.fontSize,
builder: (
BuildContext context,
PreferenceState prefState,
) {
return Column(
children: <Widget>[
if (state.item is Story)
InkWell(
onTap: () => LinkUtil.launch(
state.item.url,
useReader: context
.read<PreferenceCubit>()
.state
.readerEnabled,
offlineReading: context
.read<StoriesBloc>()
.state
.isOfflineReading,
),
child: Text.rich(
TextSpan(
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: prefState.fontSize.fontSize,
color: Theme.of(context)
.textTheme
.bodyLarge
?.color,
),
children: <TextSpan>[
TextSpan(
text: state.item.title,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: prefState.fontSize.fontSize,
color: state.item.url.isNotEmpty
? Palette.orange
: null,
),
child: Padding(
padding: const EdgeInsets.only(
left: Dimens.pt6,
right: Dimens.pt6,
bottom: Dimens.pt12,
top: Dimens.pt12,
),
child: Text.rich(
TextSpan(
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: prefState.fontSize.fontSize,
color: Theme.of(context)
.textTheme
.bodyLarge
?.color,
),
if (state.item.url.isNotEmpty)
children: <TextSpan>[
TextSpan(
text:
''' (${(state.item as Story).readableUrl})''',
semanticsLabel: state.item.title,
text: state.item.title,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize:
prefState.fontSize.fontSize - 4,
color: Palette.orange,
fontSize: prefState.fontSize.fontSize,
color: state.item.url.isNotEmpty
? Palette.orange
: null,
),
),
],
if (state.item.url.isNotEmpty)
TextSpan(
text:
''' (${(state.item as Story).readableUrl})''',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize:
prefState.fontSize.fontSize - 4,
color: Palette.orange,
),
),
],
),
textAlign: TextAlign.center,
textScaleFactor: MediaQuery.of(
context,
).textScaleFactor,
),
),
)
else
const SizedBox(
height: Dimens.pt6,
),
if (state.item.text.isNotEmpty)
SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt10,
),
child: ItemText(
item: state.item,
),
textAlign: TextAlign.center,
textScaleFactor: MediaQuery.of(
context,
).textScaleFactor,
),
),
)
else
const SizedBox(
height: Dimens.pt6,
),
if (state.item.text.isNotEmpty)
SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt10,
),
child: ItemText(
item: state.item,
),
),
),
],
);
},
),
if (state.item.isPoll)
BlocProvider<PollCubit>(
create: (BuildContext context) =>
PollCubit(story: state.item as Story)..init(),
child: const PollView(),
],
);
},
),
],
),
),
if (state.item.text.isNotEmpty)
const SizedBox(
height: Dimens.pt8,
),
const Divider(
height: Dimens.zero,
),
if (state.onlyShowTargetComment) ...<Widget>[
Center(
child: TextButton(
onPressed: () =>
context.read<CommentsCubit>().loadAll(state.item as Story),
child: const Text('View all comments'),
if (state.item.isPoll)
BlocProvider<PollCubit>(
create: (BuildContext context) =>
PollCubit(story: state.item as Story)..init(),
child: const PollView(),
),
],
),
),
if (state.item.text.isNotEmpty)
const SizedBox(
height: Dimens.pt8,
),
const Divider(
height: Dimens.zero,
),
] else ...<Widget>[
Row(
children: <Widget>[
if (state.item is Story) ...<Widget>[
const SizedBox(
width: Dimens.pt12,
),
Text(
'''${state.item.score} karma, ${state.item.descendants} comment${state.item.descendants > 1 ? 's' : ''}''',
style: const TextStyle(
fontSize: TextDimens.pt13,
if (state.onlyShowTargetComment) ...<Widget>[
Center(
child: TextButton(
onPressed: () =>
context.read<CommentsCubit>().loadAll(state.item as Story),
child: const Text('View all comments'),
),
),
const Divider(
height: Dimens.zero,
),
] else ...<Widget>[
Row(
children: <Widget>[
if (state.item is Story) ...<Widget>[
const SizedBox(
width: Dimens.pt12,
),
),
] else ...<Widget>[
const SizedBox(
width: Dimens.pt4,
),
TextButton(
onPressed: context.read<CommentsCubit>().loadParentThread,
child: state.fetchParentStatus == CommentsStatus.loading
? const SizedBox(
height: Dimens.pt12,
width: Dimens.pt12,
child: CustomCircularProgressIndicator(
strokeWidth: Dimens.pt2,
Text(
'''${state.item.score} karma, ${state.item.descendants} comment${state.item.descendants > 1 ? 's' : ''}''',
style: const TextStyle(
fontSize: TextDimens.pt13,
),
),
] else ...<Widget>[
const SizedBox(
width: Dimens.pt4,
),
TextButton(
onPressed: context.read<CommentsCubit>().loadParentThread,
child: state.fetchParentStatus == CommentsStatus.loading
? const SizedBox(
height: Dimens.pt12,
width: Dimens.pt12,
child: CustomCircularProgressIndicator(
strokeWidth: Dimens.pt2,
),
)
: const Text(
'View parent thread',
style: TextStyle(
fontSize: TextDimens.pt13,
),
),
),
],
const Spacer(),
if (!state.isOfflineReading)
DropdownButton<FetchMode>(
value: state.fetchMode,
underline: const SizedBox.shrink(),
items: FetchMode.values
.map(
(FetchMode val) => DropdownMenuItem<FetchMode>(
value: val,
child: Text(
val.description,
style: const TextStyle(
fontSize: TextDimens.pt13,
),
),
),
)
: const Text(
'View parent thread',
style: TextStyle(
fontSize: TextDimens.pt13,
),
),
.toList(),
onChanged: context.read<CommentsCubit>().onFetchModeChanged,
),
const SizedBox(
width: Dimens.pt6,
),
],
const Spacer(),
if (!state.offlineReading)
DropdownButton<FetchMode>(
value: state.fetchMode,
DropdownButton<CommentsOrder>(
value: state.order,
underline: const SizedBox.shrink(),
items: FetchMode.values
items: CommentsOrder.values
.map(
(FetchMode val) => DropdownMenuItem<FetchMode>(
(CommentsOrder val) => DropdownMenuItem<CommentsOrder>(
value: val,
child: Text(
val.description,
@ -467,51 +492,31 @@ class _ParentItemSection extends StatelessWidget {
),
)
.toList(),
onChanged: context.read<CommentsCubit>().onFetchModeChanged,
onChanged: context.read<CommentsCubit>().onOrderChanged,
),
const SizedBox(
width: Dimens.pt6,
),
DropdownButton<CommentsOrder>(
value: state.order,
underline: const SizedBox.shrink(),
items: CommentsOrder.values
.map(
(CommentsOrder val) => DropdownMenuItem<CommentsOrder>(
value: val,
child: Text(
val.description,
style: const TextStyle(
fontSize: TextDimens.pt13,
),
),
),
)
.toList(),
onChanged: context.read<CommentsCubit>().onOrderChanged,
),
const SizedBox(
width: Dimens.pt4,
),
],
),
const Divider(
height: Dimens.zero,
),
],
if (state.comments.isEmpty &&
state.status == CommentsStatus.allLoaded) ...<Widget>[
const SizedBox(
height: 240,
),
const Center(
child: Text(
'Nothing yet',
style: TextStyle(color: Palette.grey),
const SizedBox(
width: Dimens.pt4,
),
],
),
),
const Divider(
height: Dimens.zero,
),
],
if (state.comments.isEmpty &&
state.status == CommentsStatus.allLoaded) ...<Widget>[
const SizedBox(
height: 240,
),
const Center(
child: Text(
'Nothing yet',
style: TextStyle(color: Palette.grey),
),
),
],
],
],
),
);
}
}

View File

@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
@ -25,16 +23,8 @@ class MorePopupMenu extends StatelessWidget {
final bool isBlocked;
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;
}
static const double _storySheetHeight = 500;
static const double _commentSheetHeight = 480;
@override
Widget build(BuildContext context) {
@ -80,7 +70,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 ? commentHeight : storyHeight,
height: item is Comment ? _commentSheetHeight : _storySheetHeight,
color: Theme.of(context).canvasColor,
child: Material(
color: Palette.transparent,
@ -91,63 +81,71 @@ class MorePopupMenu extends StatelessWidget {
UserCubit()..init(userId: item.by),
child: BlocBuilder<UserCubit, UserState>(
builder: (BuildContext context, UserState state) {
return ListTile(
leading: const Icon(
Icons.account_circle,
),
title: Text(item.by),
subtitle: Text(
state.user.description,
),
onTap: () {
Navigator.pop(context);
showDialog<void>(
context: context,
builder: (BuildContext context) => AlertDialog(
title: Text('About ${state.user.id}'),
content: state.user.about.isEmpty
? Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: const <Widget>[
Text(
'empty',
style: TextStyle(
color: Palette.grey,
return Semantics(
excludeSemantics: state.status == UserStatus.loading,
child: ListTile(
leading: const Icon(
Icons.account_circle,
),
title: Text(item.by),
subtitle: Text(
state.user.description,
),
onTap: () {
Navigator.pop(context);
showDialog<void>(
context: context,
builder: (BuildContext context) => AlertDialog(
semanticLabel:
'''About ${state.user.id}. ${state.user.about}''',
title: Text(
'About ${state.user.id}',
),
content: state.user.about.isEmpty
? Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: const <Widget>[
Text(
'empty',
style: TextStyle(
color: Palette.grey,
),
),
],
)
: SelectableLinkify(
text: HtmlUtil.parseHtml(
state.user.about,
),
],
)
: SelectableLinkify(
text: HtmlUtil.parseHtml(
state.user.about,
linkStyle: const TextStyle(
color: Palette.orange,
),
onOpen: (LinkableElement link) =>
LinkUtil.launch(link.url),
semanticsLabel: state.user.about,
),
linkStyle: const TextStyle(
color: Palette.orange,
),
onOpen: (LinkableElement link) =>
LinkUtil.launch,
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.pop(context);
onSearchUserTapped(context);
},
child: const Text(
'Search',
),
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.pop(context);
onSearchUserTapped(context);
},
child: const Text(
'Search',
),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
'Okay',
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
'Okay',
),
),
),
],
),
);
},
],
),
);
},
),
);
},
),

View File

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

View File

@ -34,6 +34,7 @@ class _ScrollUpIconButtonState extends State<ScrollUpIconButton> {
return Opacity(
opacity: opacity.clamp(0, 1),
child: IconButton(
tooltip: 'Scroll to top',
icon: const Icon(
FeatherIcons.chevronsUp,
color: Palette.orange,

View File

@ -118,7 +118,7 @@ class InboxView extends StatelessWidget {
Row(
children: <Widget>[
Text(
e.postedDate,
e.timeAgo,
style: const TextStyle(
color: Palette.grey,
),

View File

@ -1,6 +1,7 @@
import 'dart:io';
import 'package:adaptive_theme/adaptive_theme.dart';
import 'package:clipboard/clipboard.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@ -21,7 +22,6 @@ import 'package:hacki/screens/profile/widgets/tab_bar_settings.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:logger/logger.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
@ -192,7 +192,7 @@ class _SettingsState extends State<Settings> {
.whereType<BooleanPreference>()
.where(
(Preference<dynamic> e) => e.isDisplayable,
))
)) ...<Widget>[
SwitchListTile(
title: Text(preference.title),
subtitle: preference.subtitle.isNotEmpty
@ -217,6 +217,8 @@ class _SettingsState extends State<Settings> {
},
activeColor: Palette.orange,
),
if (preference is StoryUrlModePreference) const Divider(),
],
ListTile(
title: const Text(
'Font',
@ -229,11 +231,24 @@ class _SettingsState extends State<Settings> {
),
onTap: showThemeSettingDialog,
),
const Divider(),
ListTile(
title: const Text(
'Clear Data',
'Export Favorites',
),
onTap: showClearDataDialog,
onTap: onExportFavoritesTapped,
),
ListTile(
title: const Text(
'Clear Favorites',
),
onTap: showClearFavoritesDialog,
),
ListTile(
title: const Text(
'Clear Cache',
),
onTap: showClearCacheDialog,
),
ListTile(
title: const Text('About'),
@ -376,12 +391,12 @@ class _SettingsState extends State<Settings> {
);
}
void showClearDataDialog() {
void showClearCacheDialog() {
showDialog<void>(
context: context,
builder: (_) {
return AlertDialog(
title: const Text('Clear Data?'),
title: const Text('Clear Cache?'),
content: const Text(
'Clear all cached images, stories and comments.',
),
@ -411,7 +426,7 @@ class _SettingsState extends State<Settings> {
DefaultCacheManager().emptyCache,
)
.whenComplete(() {
showSnackBar(content: 'Data cleared!');
showSnackBar(content: 'Cache cleared!');
});
},
child: const Text(
@ -462,6 +477,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(
@ -605,11 +636,64 @@ class _SettingsState extends State<Settings> {
LinkUtil.launchInExternalBrowser(Constants.githubIssueLink);
}
} catch (error, stackTrace) {
locator.get<Logger>().e(
'Error caught in onGithubTapped',
error,
stackTrace,
);
error.logError(stackTrace: stackTrace);
}
}
Future<void> onExportFavoritesTapped() async {
final List<int> allFavorites = context.read<FavCubit>().state.favIds;
if (allFavorites.isEmpty) {
showSnackBar(content: "You don't have any favorite item.");
return;
}
try {
await FlutterClipboard.copy(
allFavorites.join('\n'),
).whenComplete(HapticFeedback.selectionClick);
showSnackBar(content: 'Ids of favorites have been copied to clipboard.');
} catch (error, stackTrace) {
error.logError(stackTrace: stackTrace);
}
}
void showClearFavoritesDialog() {
showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Remove all favorites?'),
content: const Text(
'''This will not effect favorites saved in your Hacker News account.''',
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
'Cancel',
),
),
TextButton(
onPressed: () {
Navigator.pop(context);
try {
context.read<FavCubit>().removeAll();
showSnackBar(content: 'All favorites have been removed.');
} catch (error, stackTrace) {
error.logError(stackTrace: stackTrace);
}
},
child: const Text(
'Confirm',
style: TextStyle(
color: Palette.red,
),
),
),
],
);
},
);
}
}

View File

@ -70,6 +70,7 @@ class CommentTile extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Slidable(
enabled: context.isScreenReaderEnabled == false,
startActionPane: actionable
? ActionPane(
motion: const StretchMotion(),
@ -117,6 +118,14 @@ class CommentTile extends StatelessWidget {
: null,
child: InkWell(
onTap: () {
if (context.isScreenReaderEnabled) {
onMoreTapped?.call(
comment,
context.rect,
);
return;
}
if (actionable) {
HapticFeedback.selectionClick();
context.read<CollapseCubit>().collapse();
@ -152,7 +161,7 @@ class CommentTile extends StatelessWidget {
),
const Spacer(),
Text(
comment.postedDate,
comment.timeAgo,
style: const TextStyle(
color: Palette.grey,
),
@ -188,16 +197,19 @@ class CommentTile extends StatelessWidget {
),
child: SizedBox(
width: double.infinity,
child: ItemText(
key: ValueKey<int>(comment.id),
item: comment,
onTap: () {
if (onTap == null) {
_onTextTapped(context);
} else {
onTap!.call();
}
},
child: Semantics(
label: '''At level ${comment.level}.''',
child: ItemText(
key: ValueKey<int>(comment.id),
item: comment,
onTap: () {
if (onTap == null) {
_onTextTapped(context);
} else {
onTap!.call();
}
},
),
),
),
),

View File

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

View File

@ -5,6 +5,7 @@ import 'package:hacki/styles/palette.dart';
import 'package:hacki/utils/utils.dart';
import 'package:linkify/linkify.dart';
export 'package:hacki/screens/widgets/custom_linkify/linkifiers/linkifiers.dart';
export 'package:linkify/linkify.dart'
show
LinkifyElement,
@ -27,7 +28,7 @@ class Linkify extends StatelessWidget {
required this.text,
this.linkifiers = defaultLinkifiers,
this.onOpen,
this.options = const LinkifyOptions(),
this.options = LinkifierUtil.linkifyOptions,
// TextSpan
this.style,
this.linkStyle,
@ -152,9 +153,10 @@ class SelectableLinkify extends StatelessWidget {
const SelectableLinkify({
super.key,
required this.text,
this.semanticsLabel,
this.linkifiers = defaultLinkifiers,
this.onOpen,
this.options = const LinkifyOptions(),
this.options = LinkifierUtil.linkifyOptions,
// TextSpan
this.style,
this.linkStyle,
@ -187,6 +189,8 @@ class SelectableLinkify extends StatelessWidget {
/// Text to be linkified
final String text;
final String? semanticsLabel;
/// The number of font pixels for each logical pixel
final double textScaleFactor;
@ -316,6 +320,7 @@ class SelectableLinkify extends StatelessWidget {
selectionControls: selectionControls,
onSelectionChanged: onSelectionChanged,
contextMenuBuilder: contextMenuBuilder,
semanticsLabel: semanticsLabel,
);
}

View File

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:linkify/linkify.dart';
final RegExp _quoteRegex = RegExp(
r'(?=^> )(.*?)(?=\n|$)',
r'(?=^>)(.*?)(?=\n|$)',
multiLine: true,
);

View File

@ -38,15 +38,18 @@ class ItemText extends StatelessWidget {
),
onTap: onTap,
textScaleFactor: MediaQuery.of(context).textScaleFactor,
contextMenuBuilder: (
BuildContext context,
EditableTextState editableTextState,
) =>
contextMenuBuilder(
context,
editableTextState,
item: item,
),
contextMenuBuilder: context.isScreenReaderEnabled
? null
: (
BuildContext context,
EditableTextState editableTextState,
) =>
contextMenuBuilder(
context,
editableTextState,
item: item,
),
semanticsLabel: item.text,
);
} else {
return SelectableLinkify(
@ -56,15 +59,18 @@ class ItemText extends StatelessWidget {
linkStyle: linkStyle,
onOpen: (LinkableElement link) => LinkUtil.launch(link.url),
onTap: onTap,
contextMenuBuilder: (
BuildContext context,
EditableTextState editableTextState,
) =>
contextMenuBuilder(
context,
editableTextState,
item: item,
),
contextMenuBuilder: context.isScreenReaderEnabled
? null
: (
BuildContext context,
EditableTextState editableTextState,
) =>
contextMenuBuilder(
context,
editableTextState,
item: item,
),
semanticsLabel: item.text,
);
}
}

View File

@ -200,7 +200,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
Row(
children: <Widget>[
Text(
e.postedDate,
e.timeAgo,
style: const TextStyle(
color: Palette.grey,
),

View File

@ -16,10 +16,9 @@ class LinkPreview extends StatefulWidget {
required this.story,
required this.showMetadata,
required this.showUrl,
required this.offlineReading,
required this.isOfflineReading,
required this.titleStyle,
this.cache = const Duration(days: 30),
this.titleStyle,
this.bodyStyle,
this.showMultimedia = true,
this.backgroundColor = const Color.fromRGBO(235, 235, 235, 1),
this.bodyMaxLines = 3,
@ -84,10 +83,7 @@ class LinkPreview extends StatefulWidget {
final Duration cache;
/// Customize body `TextStyle`
final TextStyle? titleStyle;
/// Customize body `TextStyle`
final TextStyle? bodyStyle;
final TextStyle titleStyle;
/// Show or Hide image if available defaults to `true`
final bool showMultimedia;
@ -105,7 +101,7 @@ class LinkPreview extends StatefulWidget {
final bool showMetadata;
final bool showUrl;
final bool offlineReading;
final bool isOfflineReading;
@override
_LinkPreviewState createState() => _LinkPreviewState();
@ -135,7 +131,7 @@ class _LinkPreviewState extends State<LinkPreview> {
_info = await WebAnalyzer.getInfo(
story: widget.story,
cache: widget.cache,
offlineReading: widget.offlineReading,
offlineReading: widget.isOfflineReading,
);
if (mounted) {
@ -190,7 +186,6 @@ class _LinkPreviewState extends State<LinkPreview> {
imagePath: Constants.hackerNewsLogoPath,
onTap: _launchURL,
titleTextStyle: widget.titleStyle,
bodyTextStyle: widget.bodyStyle,
bodyTextOverflow: widget.bodyTextOverflow,
bodyMaxLines: widget.bodyMaxLines,
showMultiMedia: widget.showMultimedia,

View File

@ -1,10 +1,14 @@
import 'dart:math';
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/screens/widgets/link_preview/models/models.dart';
import 'package:hacki/styles/styles.dart';
class LinkView extends StatelessWidget {
const LinkView({
LinkView({
super.key,
required this.metadata,
required this.url,
@ -13,18 +17,18 @@ class LinkView extends StatelessWidget {
required this.description,
required this.onTap,
required this.showMetadata,
required this.showUrl,
required bool showUrl,
required this.bodyMaxLines,
required this.titleTextStyle,
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',
@ -38,35 +42,101 @@ class LinkView extends StatelessWidget {
final String? imageUri;
final String? imagePath;
final void Function(String) onTap;
final TextStyle? titleTextStyle;
final TextStyle? bodyTextStyle;
final TextStyle titleTextStyle;
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) {
double size = width * 0.13;
if (size > 15) {
size = 15;
}
return size;
}
static const double _bottomPadding = 6;
static late TextStyle _urlStyle;
static late TextStyle _metadataStyle;
static late TextStyle _descriptionStyle;
int computeTitleLines(double layoutHeight) {
return layoutHeight >= 100 ? 2 : 1;
}
static final Map<MaxLineComputationParams, int> _computationCache =
<MaxLineComputationParams, int>{};
int computeBodyLines(double layoutHeight) {
int lines = 1;
if (layoutHeight > 40) {
lines += (layoutHeight - 40.0) ~/ 15.0;
static int getDescriptionMaxLines(
MaxLineComputationParams params,
TextStyle titleStyle,
) {
if (_computationCache.containsKey(params)) {
return _computationCache[params]!;
}
return lines;
_urlStyle = titleStyle.copyWith(
color: Palette.grey,
fontSize: TextDimens.pt12,
fontWeight: FontWeight.w400,
fontFamily: params.fontFamily,
);
_descriptionStyle = TextStyle(
color: Palette.grey,
fontFamily: params.fontFamily,
fontSize: TextDimens.pt14,
);
_metadataStyle = _descriptionStyle.copyWith(
fontSize: TextDimens.pt12,
fontFamily: params.fontFamily,
);
final double urlHeight = (TextPainter(
text: TextSpan(
text: '(url)',
style: _urlStyle,
),
maxLines: 1,
textScaleFactor: params.textScaleFactor,
textDirection: TextDirection.ltr,
)..layout())
.size
.height;
final double metadataHeight = (TextPainter(
text: TextSpan(
text: '123metadata',
style: _metadataStyle,
),
maxLines: 1,
textScaleFactor: params.textScaleFactor,
textDirection: TextDirection.ltr,
)..layout())
.size
.height;
final double descriptionHeight = (TextPainter(
text: TextSpan(
text: 'DESCRIPTION',
style: _descriptionStyle,
),
maxLines: 1,
textScaleFactor: params.textScaleFactor,
textDirection: TextDirection.ltr,
)..layout())
.size
.height;
final double allPaddings =
params.fontFamily == Font.robotoSlab.name ? Dimens.pt2 : Dimens.pt4;
final double height = <double>[
params.titleHeight,
if (params.showUrl) urlHeight,
if (params.showMetadata) metadataHeight,
allPaddings,
_bottomPadding,
].reduce((double a, double b) => a + b);
final double descriptionAllowedHeight = params.layoutHeight - height;
final int maxLines =
max(1, (descriptionAllowedHeight / descriptionHeight).floor());
_computationCache[params] = maxLines;
return maxLines;
}
@override
@ -75,19 +145,36 @@ class LinkView extends StatelessWidget {
builder: (BuildContext context, BoxConstraints constraints) {
final double layoutWidth = constraints.biggest.width;
final double layoutHeight = constraints.biggest.height;
final double bodyWidth = layoutWidth - layoutHeight - 8;
final String? fontFamily =
Theme.of(context).primaryTextTheme.bodyMedium?.fontFamily;
final double textScaleFactor = MediaQuery.of(context).textScaleFactor;
final TextStyle titleFontSize = titleTextStyle ??
TextStyle(
fontSize: computeTitleFontSize(layoutWidth),
color: Palette.black,
fontWeight: FontWeight.bold,
);
final TextStyle bodyFontSize = bodyTextStyle ??
TextStyle(
fontSize: computeTitleFontSize(layoutWidth) - 1,
color: Palette.grey,
fontWeight: FontWeight.w400,
);
final TextStyle titleStyle = titleTextStyle;
final double titleHeight = (TextPainter(
text: TextSpan(
text: title,
style: titleStyle,
),
maxLines: 2,
textScaleFactor: textScaleFactor,
textDirection: TextDirection.ltr,
)..layout(maxWidth: bodyWidth))
.size
.height;
final int descriptionMaxLines = getDescriptionMaxLines(
MaxLineComputationParams(
fontFamily ?? Font.roboto.name,
bodyWidth,
layoutHeight,
titleHeight,
textScaleFactor,
showUrl,
showMetadata,
),
titleStyle,
);
return InkWell(
onTap: () => onTap(url),
@ -96,7 +183,7 @@ class LinkView extends StatelessWidget {
if (showMultiMedia)
Padding(
padding: const EdgeInsets.only(
right: 5,
right: 8,
top: 5,
bottom: 5,
),
@ -112,7 +199,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,
@ -122,24 +209,53 @@ 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),
const SizedBox(width: Dimens.pt5),
SizedBox(
height: layoutHeight,
width: layoutWidth - layoutHeight - 8,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
SizedBox(
height:
Theme.of(context).textTheme.bodyMedium?.fontFamily ==
Font.robotoSlab.name
? Dimens.pt2
: Dimens.pt4,
),
Text(
title,
style: titleStyle,
overflow: TextOverflow.ellipsis,
maxLines: 2,
),
if (showUrl)
Text(
'($readableUrl)',
textAlign: TextAlign.left,
style: _urlStyle,
overflow: bodyTextOverflow ?? TextOverflow.ellipsis,
maxLines: 1,
),
_buildBodyContainer(
bodyFontSize,
computeBodyLines(layoutHeight),
)
],
),
if (showMetadata)
Text(
metadata,
textAlign: TextAlign.left,
style: _metadataStyle,
overflow: bodyTextOverflow ?? TextOverflow.ellipsis,
maxLines: 1,
),
Text(
description,
textAlign: TextAlign.left,
style: _descriptionStyle,
overflow: TextOverflow.ellipsis,
maxLines: descriptionMaxLines,
),
const SizedBox(
height: _bottomPadding,
),
],
),
),
],
@ -148,81 +264,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),
),
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,33 @@
import 'package:equatable/equatable.dart';
class MaxLineComputationParams extends Equatable {
const MaxLineComputationParams(
this.fontFamily,
this.layoutWidth,
this.layoutHeight,
this.titleHeight,
this.textScaleFactor,
// ignore: avoid_positional_boolean_parameters
this.showUrl,
this.showMetadata,
);
final String fontFamily;
final double layoutWidth;
final double layoutHeight;
final double titleHeight;
final double textScaleFactor;
final bool showUrl;
final bool showMetadata;
@override
List<Object?> get props => <Object>[
fontFamily,
layoutWidth,
layoutHeight,
titleHeight,
textScaleFactor,
showUrl,
showMetadata,
];
}

View File

@ -0,0 +1 @@
export 'max_line_computation_params.dart';

View File

@ -17,9 +17,9 @@ class OfflineBanner extends StatelessWidget {
Widget build(BuildContext context) {
return BlocBuilder<StoriesBloc, StoriesState>(
buildWhen: (StoriesState previous, StoriesState current) =>
previous.offlineReading != current.offlineReading,
previous.isOfflineReading != current.isOfflineReading,
builder: (BuildContext context, StoriesState state) {
if (state.offlineReading) {
if (state.isOfflineReading) {
return MaterialBanner(
content: Text(
'You are currently in offline mode. '

View File

@ -52,15 +52,18 @@ class _OnboardingViewState extends State<OnboardingView> {
children: const <Widget>[
_PageViewChild(
path: Constants.commentTileRightSlidePath,
description: 'Swipe right to leave a comment or vote.',
description:
'''Swipe right to leave a comment, vote, and more.''',
),
_PageViewChild(
path: Constants.commentTileLeftSlidePath,
description: 'Swipe left to view all the parent comments.',
description:
'''Swipe left to view all the ancestor comments.''',
),
_PageViewChild(
path: Constants.commentTileTopTapPath,
description: 'Tap on the top of comment tile to collapse.',
description:
'''Tap on anywhere inside a comment tile to collapse.''',
),
],
),

View File

@ -86,7 +86,7 @@ class _StoriesListViewState extends State<StoriesListView> {
},
onTap: onStoryTapped,
onPinned: context.read<PinCubit>().pinStory,
header: state.offlineReading ? null : header,
header: state.isOfflineReading ? null : header,
onMoreTapped: onMoreTapped,
);
},

View File

@ -33,101 +33,110 @@ class StoryTile extends StatelessWidget {
Widget build(BuildContext context) {
if (showWebPreview) {
final double height = context.storyTileHeight;
return TapDownWrapper(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt12,
),
child: AbsorbPointer(
child: LinkPreview(
story: story,
link: story.url,
offlineReading: context.read<StoriesBloc>().state.offlineReading,
placeholderWidget: _LinkPreviewPlaceholder(
height: height,
return Semantics(
label: story.screenReaderLabel,
excludeSemantics: true,
child: TapDownWrapper(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt12,
),
child: AbsorbPointer(
child: LinkPreview(
story: story,
link: story.url,
isOfflineReading:
context.read<StoriesBloc>().state.isOfflineReading,
placeholderWidget: _LinkPreviewPlaceholder(
height: height,
),
errorImage: Constants.hackerNewsLogoLink,
backgroundColor: Palette.transparent,
borderRadius: Dimens.zero,
removeElevation: true,
bodyMaxLines: context.storyTileMaxLines,
errorTitle: story.title,
titleStyle: TextStyle(
color: hasRead
? Palette.grey[500]
: Theme.of(context).textTheme.bodyLarge?.color,
fontWeight: FontWeight.bold,
),
showMetadata: showMetadata,
showUrl: showUrl,
),
errorImage: Constants.hackerNewsLogoLink,
backgroundColor: Palette.transparent,
borderRadius: Dimens.zero,
removeElevation: true,
bodyMaxLines: context.storyTileMaxLines,
errorTitle: story.title,
titleStyle: TextStyle(
color: hasRead
? Palette.grey[500]
: Theme.of(context).textTheme.bodyLarge?.color,
fontWeight: FontWeight.bold,
),
showMetadata: showMetadata,
showUrl: showUrl,
),
),
),
);
} else {
return InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.only(left: Dimens.pt12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const SizedBox(
height: Dimens.pt8,
),
Row(
children: <Widget>[
Expanded(
child: Text.rich(
TextSpan(
children: <TextSpan>[
TextSpan(
text: story.title,
style: TextStyle(
color: hasRead
? Palette.grey[500]
: Theme.of(context)
.textTheme
.bodyLarge
?.color,
fontSize: simpleTileFontSize,
),
),
if (showUrl && story.url.isNotEmpty)
TextSpan(
text: ' (${story.readableUrl})',
style: TextStyle(
color: Palette.grey[500],
fontSize: simpleTileFontSize - 4,
),
),
],
),
textScaleFactor: MediaQuery.of(context).textScaleFactor,
),
),
],
),
if (showMetadata)
return Semantics(
label: story.screenReaderLabel,
excludeSemantics: true,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.only(left: Dimens.pt12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const SizedBox(
height: Dimens.pt8,
),
Row(
children: <Widget>[
Expanded(
child: Text(
story.metadata,
style: TextStyle(
color: Palette.grey,
fontSize: simpleTileFontSize - 2,
child: Text.rich(
TextSpan(
children: <TextSpan>[
TextSpan(
text: story.title,
style: TextStyle(
color: hasRead
? Palette.grey[500]
: Theme.of(context)
.textTheme
.bodyLarge
?.color,
fontSize: simpleTileFontSize,
),
),
if (showUrl && story.url.isNotEmpty)
TextSpan(
text: ' (${story.readableUrl})',
style: TextStyle(
color: Palette.grey[500],
fontSize: simpleTileFontSize - 4,
),
),
],
),
maxLines: 1,
textScaleFactor: MediaQuery.of(context).textScaleFactor,
),
),
],
),
const SizedBox(
height: Dimens.pt8,
),
],
if (showMetadata)
Row(
children: <Widget>[
Expanded(
child: Text(
story.metadata,
style: TextStyle(
color: Palette.grey,
fontSize: simpleTileFontSize - 2,
),
maxLines: 1,
),
),
],
),
const SizedBox(
height: Dimens.pt8,
),
],
),
),
),
);

View File

@ -30,6 +30,7 @@ abstract class LinkUtil {
String link, {
bool useReader = false,
bool offlineReading = false,
bool useHackiForHnLink = true,
}) {
if (offlineReading) {
locator
@ -48,7 +49,7 @@ abstract class LinkUtil {
return;
}
if (link.isStoryLink) {
if (useHackiForHnLink && link.isStoryLink) {
_onStoryLinkTapped(link);
return;
}
@ -104,7 +105,7 @@ abstract class LinkUtil {
}
});
} else {
launch(link);
launch(link, useHackiForHnLink: false);
}
}
}

View File

@ -2,8 +2,9 @@ import 'package:hacki/screens/widgets/custom_linkify/linkifiers/linkifiers.dart'
import 'package:linkify/linkify.dart';
abstract class LinkifierUtil {
static const LinkifyOptions linkifyOptions = LinkifyOptions(humanize: false);
static List<LinkifyElement> linkify(String text) {
const LinkifyOptions options = LinkifyOptions();
const List<Linkifier> linkifiers = <Linkifier>[
UrlLinkifier(),
EmailLinkifier(),
@ -21,7 +22,7 @@ abstract class LinkifierUtil {
}
for (final Linkifier linkifier in linkifiers) {
list = linkifier.parse(list, options);
list = linkifier.parse(list, linkifyOptions);
}
return list;

View File

@ -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:
@ -1351,4 +1359,4 @@ packages:
version: "3.1.1"
sdks:
dart: ">=2.19.0 <3.0.0"
flutter: ">=3.7.5"
flutter: ">=3.7.6"

View File

@ -1,11 +1,11 @@
name: hacki
description: A Hacker News reader.
version: 1.2.1+94
version: 1.3.3+102
publish_to: none
environment:
sdk: ">=2.17.0 <3.0.0"
flutter: "3.7.5"
flutter: "3.7.6"
dependencies:
adaptive_theme: ^3.0.0
@ -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