Compare commits

..

3 Commits

Author SHA1 Message Date
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
10 changed files with 245 additions and 145 deletions

View File

@ -50,7 +50,7 @@ android {
defaultConfig { defaultConfig {
applicationId "com.jiaqifeng.hacki" applicationId "com.jiaqifeng.hacki"
minSdkVersion 26 minSdkVersion 30
targetSdkVersion 33 targetSdkVersion 33
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName

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) { void _onItemLoaded(Item item) {
emit( emit(
state.copyWith( state.copyWith(

View File

@ -5,4 +5,15 @@ extension ObjectExtension on Object {
void log({String identifier = ''}) { void log({String identifier = ''}) {
locator.get<Logger>().d('$identifier ${toString()}'); 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

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

View File

@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart'; import 'package:flutter_feather_icons/flutter_feather_icons.dart';
@ -25,16 +23,8 @@ class MorePopupMenu extends StatelessWidget {
final bool isBlocked; final bool isBlocked;
final VoidCallback onLoginTapped; final VoidCallback onLoginTapped;
static double? _cachedStoryHeight; static const double _storySheetHeight = 500;
static double? _cachedCommentHeight; static const double _commentSheetHeight = 480;
static double get storyHeight {
return _cachedStoryHeight ??= Platform.isIOS ? 500 : 530;
}
static double get commentHeight {
return _cachedCommentHeight ??= Platform.isIOS ? 480 : 520;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -80,7 +70,7 @@ class MorePopupMenu extends StatelessWidget {
final bool upvoted = voteState.vote == Vote.up; final bool upvoted = voteState.vote == Vote.up;
final bool downvoted = voteState.vote == Vote.down; final bool downvoted = voteState.vote == Vote.down;
return Container( return Container(
height: item is Comment ? commentHeight : storyHeight, height: item is Comment ? _commentSheetHeight : _storySheetHeight,
color: Theme.of(context).canvasColor, color: Theme.of(context).canvasColor,
child: Material( child: Material(
color: Palette.transparent, color: Palette.transparent,

View File

@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:adaptive_theme/adaptive_theme.dart'; import 'package:adaptive_theme/adaptive_theme.dart';
import 'package:clipboard/clipboard.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@ -21,7 +22,6 @@ import 'package:hacki/screens/profile/widgets/tab_bar_settings.dart';
import 'package:hacki/screens/widgets/widgets.dart'; import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart'; import 'package:hacki/utils/utils.dart';
import 'package:logger/logger.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart'; import 'package:share_plus/share_plus.dart';
@ -192,7 +192,7 @@ class _SettingsState extends State<Settings> {
.whereType<BooleanPreference>() .whereType<BooleanPreference>()
.where( .where(
(Preference<dynamic> e) => e.isDisplayable, (Preference<dynamic> e) => e.isDisplayable,
)) )) ...<Widget>[
SwitchListTile( SwitchListTile(
title: Text(preference.title), title: Text(preference.title),
subtitle: preference.subtitle.isNotEmpty subtitle: preference.subtitle.isNotEmpty
@ -217,6 +217,8 @@ class _SettingsState extends State<Settings> {
}, },
activeColor: Palette.orange, activeColor: Palette.orange,
), ),
if (preference is StoryUrlModePreference) const Divider(),
],
ListTile( ListTile(
title: const Text( title: const Text(
'Font', 'Font',
@ -229,11 +231,24 @@ class _SettingsState extends State<Settings> {
), ),
onTap: showThemeSettingDialog, onTap: showThemeSettingDialog,
), ),
const Divider(),
ListTile( ListTile(
title: const Text( 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( ListTile(
title: const Text('About'), title: const Text('About'),
@ -376,12 +391,12 @@ class _SettingsState extends State<Settings> {
); );
} }
void showClearDataDialog() { void showClearCacheDialog() {
showDialog<void>( showDialog<void>(
context: context, context: context,
builder: (_) { builder: (_) {
return AlertDialog( return AlertDialog(
title: const Text('Clear Data?'), title: const Text('Clear Cache?'),
content: const Text( content: const Text(
'Clear all cached images, stories and comments.', 'Clear all cached images, stories and comments.',
), ),
@ -621,11 +636,64 @@ class _SettingsState extends State<Settings> {
LinkUtil.launchInExternalBrowser(Constants.githubIssueLink); LinkUtil.launchInExternalBrowser(Constants.githubIssueLink);
} }
} catch (error, stackTrace) { } catch (error, stackTrace) {
locator.get<Logger>().e( error.logError(stackTrace: stackTrace);
'Error caught in onGithubTapped',
error,
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

@ -1,10 +1,12 @@
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hacki/config/constants.dart'; import 'package:hacki/config/constants.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
import 'package:memoize/memoize.dart';
class LinkView extends StatelessWidget { class LinkView extends StatelessWidget {
const LinkView({ LinkView({
super.key, super.key,
required this.metadata, required this.metadata,
required this.url, required this.url,
@ -13,18 +15,19 @@ class LinkView extends StatelessWidget {
required this.description, required this.description,
required this.onTap, required this.onTap,
required this.showMetadata, required this.showMetadata,
required this.showUrl, required bool showUrl,
required this.bodyMaxLines,
this.imageUri, this.imageUri,
this.imagePath, this.imagePath,
this.titleTextStyle, this.titleTextStyle,
this.bodyTextStyle, this.bodyTextStyle,
this.showMultiMedia = true, this.showMultiMedia = true,
this.bodyTextOverflow, this.bodyTextOverflow,
this.bodyMaxLines,
this.isIcon = false, this.isIcon = false,
this.bgColor, this.bgColor,
this.radius = 0, this.radius = 0,
}) : assert( }) : showUrl = showUrl && url.isNotEmpty,
assert(
!showMultiMedia || !showMultiMedia ||
(showMultiMedia && (imageUri != null || imagePath != null)), (showMultiMedia && (imageUri != null || imagePath != null)),
'imageUri or imagePath cannot be null when showMultiMedia is true', 'imageUri or imagePath cannot be null when showMultiMedia is true',
@ -42,14 +45,17 @@ class LinkView extends StatelessWidget {
final TextStyle? bodyTextStyle; final TextStyle? bodyTextStyle;
final bool showMultiMedia; final bool showMultiMedia;
final TextOverflow? bodyTextOverflow; final TextOverflow? bodyTextOverflow;
final int? bodyMaxLines; final int bodyMaxLines;
final bool isIcon; final bool isIcon;
final double radius; final double radius;
final Color? bgColor; final Color? bgColor;
final bool showMetadata; final bool showMetadata;
final bool showUrl; 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; double size = width * 0.13;
if (size > 15) { if (size > 15) {
size = 15; size = 15;
@ -57,16 +63,26 @@ class LinkView extends StatelessWidget {
return size; return size;
} }
int computeTitleLines(double layoutHeight) { static final int Function(double) _getTitleLines = memo1(_computeTitleLines);
static int _computeTitleLines(double layoutHeight) {
return layoutHeight >= 100 ? 2 : 1; return layoutHeight >= 100 ? 2 : 1;
} }
int computeBodyLines(double layoutHeight) { static final int Function(int, bool, bool, String?) _getBodyLines =
int lines = 1; memo4(_computeBodyLines);
if (layoutHeight > 40) {
lines += (layoutHeight - 40.0) ~/ 15.0; static int _computeBodyLines(
} int bodyMaxLines,
return lines; 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 @override
@ -76,15 +92,15 @@ class LinkView extends StatelessWidget {
final double layoutWidth = constraints.biggest.width; final double layoutWidth = constraints.biggest.width;
final double layoutHeight = constraints.biggest.height; final double layoutHeight = constraints.biggest.height;
final TextStyle titleFontSize = titleTextStyle ?? final TextStyle titleFontStyle = titleTextStyle ??
TextStyle( TextStyle(
fontSize: computeTitleFontSize(layoutWidth), fontSize: _getTitleFontSize(layoutWidth),
color: Palette.black, color: Palette.black,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
); );
final TextStyle bodyFontSize = bodyTextStyle ?? final TextStyle bodyFontStyle = bodyTextStyle ??
TextStyle( TextStyle(
fontSize: computeTitleFontSize(layoutWidth) - 1, fontSize: _getTitleFontSize(layoutWidth) - 1,
color: Palette.grey, color: Palette.grey,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
); );
@ -96,7 +112,7 @@ class LinkView extends StatelessWidget {
if (showMultiMedia) if (showMultiMedia)
Padding( Padding(
padding: const EdgeInsets.only( padding: const EdgeInsets.only(
right: 5, right: 8,
top: 5, top: 5,
bottom: 5, bottom: 5,
), ),
@ -112,7 +128,7 @@ class LinkView extends StatelessWidget {
imageUrl: imageUri!, imageUrl: imageUri!,
fit: isIcon ? BoxFit.scaleDown : BoxFit.fitWidth, fit: isIcon ? BoxFit.scaleDown : BoxFit.fitWidth,
memCacheHeight: layoutHeight.toInt() * 4, memCacheHeight: layoutHeight.toInt() * 4,
errorWidget: (BuildContext context, _, dynamic __) { errorWidget: (BuildContext context, _, __) {
return Image.asset( return Image.asset(
Constants.hackerNewsLogoPath, Constants.hackerNewsLogoPath,
fit: BoxFit.cover, fit: BoxFit.cover,
@ -124,22 +140,85 @@ class LinkView extends StatelessWidget {
else else
const SizedBox(width: 5), const SizedBox(width: 5),
Expanded( Expanded(
flex: 4, child: Column(
child: Padding( mainAxisAlignment: MainAxisAlignment.center,
padding: const EdgeInsets.symmetric(vertical: 3), children: <Widget>[
child: Column( Padding(
mainAxisAlignment: MainAxisAlignment.center, padding: EdgeInsets.only(
children: <Widget>[ top: Theme.of(context)
_buildTitleContainer( .textTheme
titleFontSize, .bodyMedium
computeTitleLines(layoutHeight), ?.fontFamily ==
Font.robotoSlab.name
? 2
: 4,
), ),
_buildBodyContainer( child: Column(
bodyFontSize, children: <Widget>[
computeBodyLines(layoutHeight), 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),
),
),
),
],
),
),
);
}
} }

View File

@ -600,6 +600,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.0" version: "0.2.0"
memoize:
dependency: "direct main"
description:
name: memoize
sha256: "51481d328c86cbdc59711369179bac88551ca0556569249be5317e66fc796cac"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
meta: meta:
dependency: transitive dependency: transitive
description: description:

View File

@ -1,6 +1,6 @@
name: hacki name: hacki
description: A Hacker News reader. description: A Hacker News reader.
version: 1.2.4+97 version: 1.3.0+99
publish_to: none publish_to: none
environment: environment:
@ -45,6 +45,7 @@ dependencies:
intl: ^0.18.0 intl: ^0.18.0
linkify: ^4.1.0 linkify: ^4.1.0
logger: ^1.1.0 logger: ^1.1.0
memoize: ^3.0.0
package_info_plus: ^3.0.3 package_info_plus: ^3.0.3
path: ^1.8.2 path: ^1.8.2
path_provider: ^2.0.12 path_provider: ^2.0.12