Compare commits

...

6 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
42 changed files with 911 additions and 678 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
@ -64,12 +64,15 @@ android {
storePassword keystoreProperties['storePassword'] storePassword keystoreProperties['storePassword']
} }
} }
buildTypes { buildTypes {
release { release {
signingConfig signingConfigs.release signingConfig signingConfigs.release
minifyEnabled true
shrinkResources true
} }
} }
} }
flutter { flutter {

View File

@ -37,15 +37,6 @@
android:name="io.flutter.embedding.android.NormalTheme" android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/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> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>

View File

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

View File

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

View File

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

View File

@ -17,12 +17,12 @@ class CommentsState extends Equatable {
required this.order, required this.order,
required this.fetchMode, required this.fetchMode,
required this.onlyShowTargetComment, required this.onlyShowTargetComment,
required this.offlineReading, required this.isOfflineReading,
required this.currentPage, required this.currentPage,
}); });
CommentsState.init({ CommentsState.init({
required this.offlineReading, required this.isOfflineReading,
required this.item, required this.item,
required this.fetchMode, required this.fetchMode,
required this.order, required this.order,
@ -39,7 +39,7 @@ class CommentsState extends Equatable {
final CommentsOrder order; final CommentsOrder order;
final FetchMode fetchMode; final FetchMode fetchMode;
final bool onlyShowTargetComment; final bool onlyShowTargetComment;
final bool offlineReading; final bool isOfflineReading;
final int currentPage; final int currentPage;
CommentsState copyWith({ CommentsState copyWith({
@ -50,7 +50,7 @@ class CommentsState extends Equatable {
CommentsOrder? order, CommentsOrder? order,
FetchMode? fetchMode, FetchMode? fetchMode,
bool? onlyShowTargetComment, bool? onlyShowTargetComment,
bool? offlineReading, bool? isOfflineReading,
int? currentPage, int? currentPage,
}) { }) {
return CommentsState( return CommentsState(
@ -62,7 +62,7 @@ class CommentsState extends Equatable {
fetchMode: fetchMode ?? this.fetchMode, fetchMode: fetchMode ?? this.fetchMode,
onlyShowTargetComment: onlyShowTargetComment:
onlyShowTargetComment ?? this.onlyShowTargetComment, onlyShowTargetComment ?? this.onlyShowTargetComment,
offlineReading: offlineReading ?? this.offlineReading, isOfflineReading: isOfflineReading ?? this.isOfflineReading,
currentPage: currentPage ?? this.currentPage, currentPage: currentPage ?? this.currentPage,
); );
} }
@ -77,7 +77,7 @@ class CommentsState extends Equatable {
order, order,
fetchMode, fetchMode,
onlyShowTargetComment, onlyShowTargetComment,
offlineReading, isOfflineReading,
currentPage, currentPage,
comments, 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) { void _onItemLoaded(Item item) {
emit( emit(
state.copyWith( state.copyWith(

View File

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

View File

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

View File

@ -2,7 +2,18 @@ import 'package:hacki/config/locator.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
extension ObjectExtension on Object { 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

@ -1,7 +1,7 @@
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/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'; import 'package:hacki/utils/utils.dart';
extension WidgetModifier on Widget { extension WidgetModifier on Widget {

View File

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

View File

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

View File

@ -43,10 +43,13 @@ class Story extends Item {
Story.fromJson(super.json) : super.fromJson(); Story.fromJson(super.json) : super.fromJson();
String get metadata => 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 => 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 { String get readableUrl {
final Uri url = Uri.parse(this.url); final Uri url = Uri.parse(this.url);
@ -55,10 +58,5 @@ class Story extends Item {
} }
@override @override
String toString() { String toString() => 'Story $id';
// final String prettyString =
// const JsonEncoder.withIndent(' ').convert(this);
// return 'Story $prettyString';
return 'Story $id';
}
} }

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

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

View File

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

View File

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

View File

@ -24,9 +24,7 @@ class LinkIconButton extends StatelessWidget {
featureId: Constants.featureOpenStoryInWebView, featureId: Constants.featureOpenStoryInWebView,
title: Text('Open in Browser'), title: Text('Open in Browser'),
description: Text( description: Text(
'Want more than just reading and replying? ' '''You can tap here to open this story in browser.''',
'You can tap here to open this story in a '
'browser.',
style: TextStyle(fontSize: TextDimens.pt16), style: TextStyle(fontSize: TextDimens.pt16),
), ),
child: Icon( child: Icon(

View File

@ -87,7 +87,7 @@ class MainView extends StatelessWidget {
onRefresh: () { onRefresh: () {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
if (context.read<StoriesBloc>().state.offlineReading) { if (context.read<StoriesBloc>().state.isOfflineReading) {
refreshController.refreshCompleted(); refreshController.refreshCompleted();
} else { } else {
context.read<CommentsCubit>().refresh(); context.read<CommentsCubit>().refresh();
@ -231,232 +231,257 @@ class _ParentItemSection extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Column( return Semantics(
children: <Widget>[ label:
SizedBox( '''Posted by ${state.item.by} ${state.item.timeAgo}, ${state.item.title}. ${state.item.text}''',
height: topPadding, child: Column(
), children: <Widget>[
if (!splitViewEnabled) SizedBox(
const Padding( height: topPadding,
padding: EdgeInsets.only(bottom: Dimens.pt6),
child: OfflineBanner(),
), ),
Slidable( if (!splitViewEnabled)
startActionPane: ActionPane( const Padding(
motion: const BehindMotion(), padding: EdgeInsets.only(bottom: Dimens.pt6),
children: <Widget>[ child: OfflineBanner(),
SlidableAction( ),
onPressed: (_) { Slidable(
HapticFeedback.lightImpact(); startActionPane: ActionPane(
motion: const BehindMotion(),
children: <Widget>[
SlidableAction(
onPressed: (_) {
HapticFeedback.lightImpact();
if (state.item.id != if (state.item.id !=
context.read<EditCubit>().state.replyingTo?.id) { context.read<EditCubit>().state.replyingTo?.id) {
commentEditingController.clear(); commentEditingController.clear();
} }
context.read<EditCubit>().onReplyTapped(state.item); context.read<EditCubit>().onReplyTapped(state.item);
focusNode.requestFocus(); focusNode.requestFocus();
}, },
backgroundColor: Palette.orange, backgroundColor: Palette.orange,
foregroundColor: Palette.white, foregroundColor: Palette.white,
icon: Icons.message, 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,
), ),
child: Row( SlidableAction(
children: <Widget>[ onPressed: (BuildContext context) =>
Text( onMoreTapped(state.item, context.rect),
state.item.by, backgroundColor: Palette.orange,
style: const TextStyle( foregroundColor: Palette.white,
color: Palette.orange, icon: Icons.more_horiz,
),
),
const Spacer(),
Text(
state.item.postedDate,
style: const TextStyle(
color: Palette.grey,
),
),
],
), ),
), ],
BlocBuilder<PreferenceCubit, PreferenceState>( ),
buildWhen: ( child: Column(
PreferenceState previous, children: <Widget>[
PreferenceState current, Padding(
) => padding: const EdgeInsets.only(
previous.fontSize != current.fontSize, left: Dimens.pt6,
builder: ( right: Dimens.pt6,
BuildContext context, ),
PreferenceState prefState, child: Row(
) {
return Column(
children: <Widget>[ children: <Widget>[
if (state.item is Story) Text(
InkWell( state.item.by,
onTap: () => LinkUtil.launch( style: const TextStyle(
state.item.url, color: Palette.orange,
useReader: context ),
.read<PreferenceCubit>() ),
.state const Spacer(),
.readerEnabled, Text(
offlineReading: context state.item.timeAgo,
.read<StoriesBloc>() style: const TextStyle(
.state color: Palette.grey,
.offlineReading, ),
), ),
child: Padding( ],
padding: const EdgeInsets.only( ),
left: Dimens.pt6, ),
right: Dimens.pt6, BlocBuilder<PreferenceCubit, PreferenceState>(
bottom: Dimens.pt12, buildWhen: (
top: Dimens.pt12, 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( child: Padding(
TextSpan( padding: const EdgeInsets.only(
style: TextStyle( left: Dimens.pt6,
fontWeight: FontWeight.bold, right: Dimens.pt6,
fontSize: prefState.fontSize.fontSize, bottom: Dimens.pt12,
color: Theme.of(context) top: Dimens.pt12,
.textTheme ),
.bodyLarge child: Text.rich(
?.color, TextSpan(
), style: TextStyle(
children: <TextSpan>[ fontWeight: FontWeight.bold,
TextSpan( fontSize: prefState.fontSize.fontSize,
text: state.item.title, color: Theme.of(context)
style: TextStyle( .textTheme
fontWeight: FontWeight.bold, .bodyLarge
fontSize: prefState.fontSize.fontSize, ?.color,
color: state.item.url.isNotEmpty
? Palette.orange
: null,
),
), ),
if (state.item.url.isNotEmpty) children: <TextSpan>[
TextSpan( TextSpan(
text: semanticsLabel: state.item.title,
''' (${(state.item as Story).readableUrl})''', text: state.item.title,
style: TextStyle( style: TextStyle(
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: fontSize: prefState.fontSize.fontSize,
prefState.fontSize.fontSize - 4, color: state.item.url.isNotEmpty
color: Palette.orange, ? 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.isPoll)
), BlocProvider<PollCubit>(
), create: (BuildContext context) =>
if (state.item.text.isNotEmpty) PollCubit(story: state.item as Story)..init(),
const SizedBox( child: const PollView(),
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.text.isNotEmpty)
const SizedBox(
height: Dimens.pt8,
),
const Divider( const Divider(
height: Dimens.zero, height: Dimens.zero,
), ),
] else ...<Widget>[ if (state.onlyShowTargetComment) ...<Widget>[
Row( Center(
children: <Widget>[ child: TextButton(
if (state.item is Story) ...<Widget>[ onPressed: () =>
const SizedBox( context.read<CommentsCubit>().loadAll(state.item as Story),
width: Dimens.pt12, child: const Text('View all comments'),
), ),
Text( ),
'''${state.item.score} karma, ${state.item.descendants} comment${state.item.descendants > 1 ? 's' : ''}''', const Divider(
style: const TextStyle( height: Dimens.zero,
fontSize: TextDimens.pt13, ),
] else ...<Widget>[
Row(
children: <Widget>[
if (state.item is Story) ...<Widget>[
const SizedBox(
width: Dimens.pt12,
), ),
), Text(
] else ...<Widget>[ '''${state.item.score} karma, ${state.item.descendants} comment${state.item.descendants > 1 ? 's' : ''}''',
const SizedBox( style: const TextStyle(
width: Dimens.pt4, fontSize: TextDimens.pt13,
), ),
TextButton( ),
onPressed: context.read<CommentsCubit>().loadParentThread, ] else ...<Widget>[
child: state.fetchParentStatus == CommentsStatus.loading const SizedBox(
? const SizedBox( width: Dimens.pt4,
height: Dimens.pt12, ),
width: Dimens.pt12, TextButton(
child: CustomCircularProgressIndicator( onPressed: context.read<CommentsCubit>().loadParentThread,
strokeWidth: Dimens.pt2, 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( .toList(),
'View parent thread', onChanged: context.read<CommentsCubit>().onFetchModeChanged,
style: TextStyle( ),
fontSize: TextDimens.pt13, const SizedBox(
), width: Dimens.pt6,
),
), ),
], DropdownButton<CommentsOrder>(
const Spacer(), value: state.order,
if (!state.offlineReading)
DropdownButton<FetchMode>(
value: state.fetchMode,
underline: const SizedBox.shrink(), underline: const SizedBox.shrink(),
items: FetchMode.values items: CommentsOrder.values
.map( .map(
(FetchMode val) => DropdownMenuItem<FetchMode>( (CommentsOrder val) => DropdownMenuItem<CommentsOrder>(
value: val, value: val,
child: Text( child: Text(
val.description, val.description,
@ -467,51 +492,31 @@ class _ParentItemSection extends StatelessWidget {
), ),
) )
.toList(), .toList(),
onChanged: context.read<CommentsCubit>().onFetchModeChanged, onChanged: context.read<CommentsCubit>().onOrderChanged,
), ),
const SizedBox( const SizedBox(
width: Dimens.pt6, width: Dimens.pt4,
), ),
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 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/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,
@ -91,63 +81,71 @@ class MorePopupMenu extends StatelessWidget {
UserCubit()..init(userId: item.by), UserCubit()..init(userId: item.by),
child: BlocBuilder<UserCubit, UserState>( child: BlocBuilder<UserCubit, UserState>(
builder: (BuildContext context, UserState state) { builder: (BuildContext context, UserState state) {
return ListTile( return Semantics(
leading: const Icon( excludeSemantics: state.status == UserStatus.loading,
Icons.account_circle, child: ListTile(
), leading: const Icon(
title: Text(item.by), Icons.account_circle,
subtitle: Text( ),
state.user.description, title: Text(item.by),
), subtitle: Text(
onTap: () { state.user.description,
Navigator.pop(context); ),
showDialog<void>( onTap: () {
context: context, Navigator.pop(context);
builder: (BuildContext context) => AlertDialog( showDialog<void>(
title: Text('About ${state.user.id}'), context: context,
content: state.user.about.isEmpty builder: (BuildContext context) => AlertDialog(
? Row( semanticLabel:
mainAxisAlignment: '''About ${state.user.id}. ${state.user.about}''',
MainAxisAlignment.center, title: Text(
children: const <Widget>[ 'About ${state.user.id}',
Text( ),
'empty', content: state.user.about.isEmpty
style: TextStyle( ? Row(
color: Palette.grey, mainAxisAlignment:
MainAxisAlignment.center,
children: const <Widget>[
Text(
'empty',
style: TextStyle(
color: Palette.grey,
),
), ),
],
)
: SelectableLinkify(
text: HtmlUtil.parseHtml(
state.user.about,
), ),
], linkStyle: const TextStyle(
) color: Palette.orange,
: SelectableLinkify( ),
text: HtmlUtil.parseHtml( onOpen: (LinkableElement link) =>
state.user.about, LinkUtil.launch(link.url),
semanticsLabel: state.user.about,
), ),
linkStyle: const TextStyle( actions: <Widget>[
color: Palette.orange, TextButton(
), onPressed: () {
onOpen: (LinkableElement link) => Navigator.pop(context);
LinkUtil.launch(link.url), onSearchUserTapped(context);
},
child: const Text(
'Search',
), ),
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.pop(context);
onSearchUserTapped(context);
},
child: const Text(
'Search',
), ),
), TextButton(
TextButton( onPressed: () => Navigator.pop(context),
onPressed: () => Navigator.pop(context), child: const Text(
child: const Text( 'Okay',
'Okay', ),
), ),
), ],
], ),
), );
); },
}, ),
); );
}, },
), ),

View File

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

View File

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

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.',
), ),
@ -411,7 +426,7 @@ class _SettingsState extends State<Settings> {
DefaultCacheManager().emptyCache, DefaultCacheManager().emptyCache,
) )
.whenComplete(() { .whenComplete(() {
showSnackBar(content: 'Data cleared!'); showSnackBar(content: 'Cache cleared!');
}); });
}, },
child: const Text( child: const Text(
@ -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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,14 @@
import 'dart:math';
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/screens/widgets/link_preview/models/models.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.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 +17,18 @@ 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,
required this.titleTextStyle,
this.imageUri, this.imageUri,
this.imagePath, this.imagePath,
this.titleTextStyle,
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',
@ -38,35 +42,101 @@ class LinkView extends StatelessWidget {
final String? imageUri; final String? imageUri;
final String? imagePath; final String? imagePath;
final void Function(String) onTap; final void Function(String) onTap;
final TextStyle? titleTextStyle; final TextStyle titleTextStyle;
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 const double _bottomPadding = 6;
double size = width * 0.13; static late TextStyle _urlStyle;
if (size > 15) { static late TextStyle _metadataStyle;
size = 15; static late TextStyle _descriptionStyle;
}
return size;
}
int computeTitleLines(double layoutHeight) { static final Map<MaxLineComputationParams, int> _computationCache =
return layoutHeight >= 100 ? 2 : 1; <MaxLineComputationParams, int>{};
}
int computeBodyLines(double layoutHeight) { static int getDescriptionMaxLines(
int lines = 1; MaxLineComputationParams params,
if (layoutHeight > 40) { TextStyle titleStyle,
lines += (layoutHeight - 40.0) ~/ 15.0; ) {
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 @override
@ -75,19 +145,36 @@ class LinkView extends StatelessWidget {
builder: (BuildContext context, BoxConstraints constraints) { builder: (BuildContext context, BoxConstraints constraints) {
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 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 ?? final TextStyle titleStyle = titleTextStyle;
TextStyle( final double titleHeight = (TextPainter(
fontSize: computeTitleFontSize(layoutWidth), text: TextSpan(
color: Palette.black, text: title,
fontWeight: FontWeight.bold, style: titleStyle,
); ),
final TextStyle bodyFontSize = bodyTextStyle ?? maxLines: 2,
TextStyle( textScaleFactor: textScaleFactor,
fontSize: computeTitleFontSize(layoutWidth) - 1, textDirection: TextDirection.ltr,
color: Palette.grey, )..layout(maxWidth: bodyWidth))
fontWeight: FontWeight.w400, .size
); .height;
final int descriptionMaxLines = getDescriptionMaxLines(
MaxLineComputationParams(
fontFamily ?? Font.roboto.name,
bodyWidth,
layoutHeight,
titleHeight,
textScaleFactor,
showUrl,
showMetadata,
),
titleStyle,
);
return InkWell( return InkWell(
onTap: () => onTap(url), onTap: () => onTap(url),
@ -96,7 +183,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 +199,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,
@ -122,24 +209,53 @@ class LinkView extends StatelessWidget {
), ),
) )
else else
const SizedBox(width: 5), const SizedBox(width: Dimens.pt5),
Expanded( SizedBox(
flex: 4, height: layoutHeight,
child: Padding( width: layoutWidth - layoutHeight - 8,
padding: const EdgeInsets.symmetric(vertical: 3), child: Column(
child: Column( crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[
children: <Widget>[ SizedBox(
_buildTitleContainer( height:
titleFontSize, Theme.of(context).textTheme.bodyMedium?.fontFamily ==
computeTitleLines(layoutHeight), 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( if (showMetadata)
bodyFontSize, Text(
computeBodyLines(layoutHeight), 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) { Widget build(BuildContext context) {
return BlocBuilder<StoriesBloc, StoriesState>( return BlocBuilder<StoriesBloc, StoriesState>(
buildWhen: (StoriesState previous, StoriesState current) => buildWhen: (StoriesState previous, StoriesState current) =>
previous.offlineReading != current.offlineReading, previous.isOfflineReading != current.isOfflineReading,
builder: (BuildContext context, StoriesState state) { builder: (BuildContext context, StoriesState state) {
if (state.offlineReading) { if (state.isOfflineReading) {
return MaterialBanner( return MaterialBanner(
content: Text( content: Text(
'You are currently in offline mode. ' 'You are currently in offline mode. '

View File

@ -52,15 +52,18 @@ class _OnboardingViewState extends State<OnboardingView> {
children: const <Widget>[ children: const <Widget>[
_PageViewChild( _PageViewChild(
path: Constants.commentTileRightSlidePath, path: Constants.commentTileRightSlidePath,
description: 'Swipe right to leave a comment or vote.', description:
'''Swipe right to leave a comment, vote, and more.''',
), ),
_PageViewChild( _PageViewChild(
path: Constants.commentTileLeftSlidePath, path: Constants.commentTileLeftSlidePath,
description: 'Swipe left to view all the parent comments.', description:
'''Swipe left to view all the ancestor comments.''',
), ),
_PageViewChild( _PageViewChild(
path: Constants.commentTileTopTapPath, 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, onTap: onStoryTapped,
onPinned: context.read<PinCubit>().pinStory, onPinned: context.read<PinCubit>().pinStory,
header: state.offlineReading ? null : header, header: state.isOfflineReading ? null : header,
onMoreTapped: onMoreTapped, onMoreTapped: onMoreTapped,
); );
}, },

View File

@ -33,101 +33,110 @@ class StoryTile extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (showWebPreview) { if (showWebPreview) {
final double height = context.storyTileHeight; final double height = context.storyTileHeight;
return TapDownWrapper( return Semantics(
onTap: onTap, label: story.screenReaderLabel,
child: Padding( excludeSemantics: true,
padding: const EdgeInsets.symmetric( child: TapDownWrapper(
horizontal: Dimens.pt12, onTap: onTap,
), child: Padding(
child: AbsorbPointer( padding: const EdgeInsets.symmetric(
child: LinkPreview( horizontal: Dimens.pt12,
story: story, ),
link: story.url, child: AbsorbPointer(
offlineReading: context.read<StoriesBloc>().state.offlineReading, child: LinkPreview(
placeholderWidget: _LinkPreviewPlaceholder( story: story,
height: height, 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 { } else {
return InkWell( return Semantics(
onTap: onTap, label: story.screenReaderLabel,
child: Padding( excludeSemantics: true,
padding: const EdgeInsets.only(left: Dimens.pt12), child: InkWell(
child: Column( onTap: onTap,
crossAxisAlignment: CrossAxisAlignment.start, child: Padding(
children: <Widget>[ padding: const EdgeInsets.only(left: Dimens.pt12),
const SizedBox( child: Column(
height: Dimens.pt8, crossAxisAlignment: CrossAxisAlignment.start,
), children: <Widget>[
Row( const SizedBox(
children: <Widget>[ height: Dimens.pt8,
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)
Row( Row(
children: <Widget>[ children: <Widget>[
Expanded( Expanded(
child: Text( child: Text.rich(
story.metadata, TextSpan(
style: TextStyle( children: <TextSpan>[
color: Palette.grey, TextSpan(
fontSize: simpleTileFontSize - 2, 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( if (showMetadata)
height: Dimens.pt8, 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

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

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

View File

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