mirror of
https://github.com/Livinglist/Hacki.git
synced 2025-08-06 18:24:42 +08:00
Compare commits
7 Commits
Author | SHA1 | Date | |
---|---|---|---|
aa6a2c684c | |||
d4778d9530 | |||
c702e08481 | |||
2af10391bc | |||
c420dd3ca4 | |||
da7d0757cd | |||
32ae2087bc |
@ -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 {
|
||||
|
@ -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
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.
|
@ -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';
|
||||
|
@ -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(
|
||||
|
@ -5,4 +5,15 @@ extension ObjectExtension on Object {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
@ -154,7 +155,7 @@ class SelectableLinkify extends StatelessWidget {
|
||||
required this.text,
|
||||
this.linkifiers = defaultLinkifiers,
|
||||
this.onOpen,
|
||||
this.options = const LinkifyOptions(),
|
||||
this.options = LinkifierUtil.linkifyOptions,
|
||||
// TextSpan
|
||||
this.style,
|
||||
this.linkStyle,
|
||||
|
@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:linkify/linkify.dart';
|
||||
|
||||
final RegExp _quoteRegex = RegExp(
|
||||
r'(?=^> )(.*?)(?=\n|$)',
|
||||
r'(?=^>)(.*?)(?=\n|$)',
|
||||
multiLine: true,
|
||||
);
|
||||
|
||||
|
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
@ -103,6 +104,8 @@ abstract class LinkUtil {
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
launch(link, useHackiForHnLink: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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.2+95
|
||||
version: 1.3.1+100
|
||||
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