mirror of
https://github.com/Livinglist/Hacki.git
synced 2025-08-06 18:24:42 +08:00
Compare commits
8 Commits
Author | SHA1 | Date | |
---|---|---|---|
e15dcba93b | |||
1362b93a74 | |||
ac18793f98 | |||
e52f65c773 | |||
06212a0d72 | |||
e77c0e3e73 | |||
cb6f41ec49 | |||
ab1e90ccad |
@ -50,7 +50,7 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.jiaqifeng.hacki"
|
||||
minSdkVersion 30
|
||||
minSdkVersion 26
|
||||
targetSdkVersion 33
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
|
@ -2,6 +2,8 @@ PODS:
|
||||
- connectivity_plus (0.0.1):
|
||||
- Flutter
|
||||
- ReachabilitySwift
|
||||
- device_info_plus (0.0.1):
|
||||
- Flutter
|
||||
- Flutter (1.0.0)
|
||||
- flutter_email_sender (0.0.1):
|
||||
- Flutter
|
||||
@ -53,6 +55,7 @@ PODS:
|
||||
|
||||
DEPENDENCIES:
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_email_sender (from `.symlinks/plugins/flutter_email_sender/ios`)
|
||||
- flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`)
|
||||
@ -81,6 +84,8 @@ SPEC REPOS:
|
||||
EXTERNAL SOURCES:
|
||||
connectivity_plus:
|
||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||
device_info_plus:
|
||||
:path: ".symlinks/plugins/device_info_plus/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_email_sender:
|
||||
@ -120,6 +125,7 @@ EXTERNAL SOURCES:
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
connectivity_plus: 413a8857dd5d9f1c399a39130850d02fe0feaf7e
|
||||
device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed
|
||||
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
|
||||
flutter_email_sender: 02d7443217d8c41483223627972bfdc09f74276b
|
||||
flutter_inappwebview: bfd58618f49dc62f2676de690fc6dcda1d6c3721
|
||||
|
@ -41,7 +41,12 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
await _authRepository.loggedIn.then((bool loggedIn) async {
|
||||
if (loggedIn) {
|
||||
final String? username = await _authRepository.username;
|
||||
final User user = await _storiesRepository.fetchUser(id: username!);
|
||||
User? user = await _storiesRepository.fetchUser(id: username!);
|
||||
|
||||
/// According to Hacker News' API documentation,
|
||||
/// if user has no public activity (posting a comment or story),
|
||||
/// then it will not be available from the API.
|
||||
user ??= User.emptyWithId(username);
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
@ -84,10 +89,10 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
);
|
||||
|
||||
if (successful) {
|
||||
final User user = await _storiesRepository.fetchUser(id: event.username);
|
||||
final User? user = await _storiesRepository.fetchUser(id: event.username);
|
||||
emit(
|
||||
state.copyWith(
|
||||
user: user,
|
||||
user: user ?? User.emptyWithId(event.username),
|
||||
isLoggedIn: true,
|
||||
status: AuthStatus.loaded,
|
||||
),
|
||||
|
@ -17,11 +17,13 @@ part 'stories_state.dart';
|
||||
class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
StoriesBloc({
|
||||
required PreferenceCubit preferenceCubit,
|
||||
required FilterCubit filterCubit,
|
||||
OfflineRepository? offlineRepository,
|
||||
StoriesRepository? storiesRepository,
|
||||
PreferenceRepository? preferenceRepository,
|
||||
Logger? logger,
|
||||
}) : _preferenceCubit = preferenceCubit,
|
||||
_filterCubit = filterCubit,
|
||||
_offlineRepository =
|
||||
offlineRepository ?? locator.get<OfflineRepository>(),
|
||||
_storiesRepository =
|
||||
@ -45,6 +47,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
}
|
||||
|
||||
final PreferenceCubit _preferenceCubit;
|
||||
final FilterCubit _filterCubit;
|
||||
final OfflineRepository _offlineRepository;
|
||||
final StoriesRepository _storiesRepository;
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
@ -224,10 +227,15 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
Emitter<StoriesState> emit,
|
||||
) async {
|
||||
final bool hasRead = await _preferenceRepository.hasRead(event.story.id);
|
||||
final bool hidden = _filterCubit.state.keywords.any(
|
||||
(String keyword) =>
|
||||
event.story.title.toLowerCase().contains(keyword) ||
|
||||
event.story.text.toLowerCase().contains(keyword),
|
||||
);
|
||||
emit(
|
||||
state.copyWithStoryAdded(
|
||||
type: event.type,
|
||||
story: event.story,
|
||||
story: event.story.copyWith(hidden: hidden),
|
||||
hasRead: hasRead,
|
||||
),
|
||||
);
|
||||
|
@ -7,7 +7,7 @@ abstract class Constants {
|
||||
'https://github.com/Livinglist/Hacki/blob/master/assets/privacy_policy.md';
|
||||
static const String hackerNewsLogoLink =
|
||||
'https://pbs.twimg.com/profile_images/469397708986269696/iUrYEOpJ_400x400.png';
|
||||
static const String portfolioLink = 'https://livinglist.github.io';
|
||||
static const String portfolioLink = 'https://github.com/Livinglist';
|
||||
static const String githubLink = 'https://github.com/Livinglist/Hacki';
|
||||
static const String appStoreLink =
|
||||
'https://apps.apple.com/us/app/hacki/id1602043763?action=write-review';
|
||||
|
@ -6,6 +6,7 @@ import 'package:equatable/equatable.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/main.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
@ -20,18 +21,19 @@ part 'comments_state.dart';
|
||||
|
||||
class CommentsCubit extends Cubit<CommentsState> {
|
||||
CommentsCubit({
|
||||
required FilterCubit filterCubit,
|
||||
required CollapseCache collapseCache,
|
||||
CommentCache? commentCache,
|
||||
OfflineRepository? offlineRepository,
|
||||
StoriesRepository? storiesRepository,
|
||||
SembastRepository? sembastRepository,
|
||||
Logger? logger,
|
||||
required bool isScreenReaderEnabled,
|
||||
required bool isOfflineReading,
|
||||
required Item item,
|
||||
required FetchMode defaultFetchMode,
|
||||
required CommentsOrder defaultCommentsOrder,
|
||||
}) : _collapseCache = collapseCache,
|
||||
}) : _filterCubit = filterCubit,
|
||||
_collapseCache = collapseCache,
|
||||
_commentCache = commentCache ?? locator.get<CommentCache>(),
|
||||
_offlineRepository =
|
||||
offlineRepository ?? locator.get<OfflineRepository>(),
|
||||
@ -40,7 +42,6 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
_sembastRepository =
|
||||
sembastRepository ?? locator.get<SembastRepository>(),
|
||||
_logger = logger ?? locator.get<Logger>(),
|
||||
_isScreenReaderEnabled = isScreenReaderEnabled,
|
||||
super(
|
||||
CommentsState.init(
|
||||
isOfflineReading: isOfflineReading,
|
||||
@ -50,13 +51,13 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
),
|
||||
);
|
||||
|
||||
final FilterCubit _filterCubit;
|
||||
final CollapseCache _collapseCache;
|
||||
final CommentCache _commentCache;
|
||||
final OfflineRepository _offlineRepository;
|
||||
final StoriesRepository _storiesRepository;
|
||||
final SembastRepository _sembastRepository;
|
||||
final Logger _logger;
|
||||
final bool _isScreenReaderEnabled;
|
||||
|
||||
/// The [StreamSubscription] for stream (both lazy or eager)
|
||||
/// fetching comments posted directly to the story.
|
||||
@ -351,17 +352,17 @@ class CommentsCubit extends Cubit<CommentsState> {
|
||||
_commentCache.cacheComment(comment);
|
||||
_sembastRepository.cacheComment(comment);
|
||||
|
||||
final bool hidden = _filterCubit.state.keywords.any(
|
||||
(String keyword) => comment.text.toLowerCase().contains(keyword),
|
||||
);
|
||||
final List<Comment> updatedComments = <Comment>[
|
||||
...state.comments,
|
||||
comment
|
||||
comment.copyWith(hidden: hidden),
|
||||
];
|
||||
|
||||
emit(state.copyWith(comments: updatedComments));
|
||||
|
||||
if (state.fetchMode == FetchMode.eager) {
|
||||
/// If screen reader is on, fetch all the comments without paging.
|
||||
if (_isScreenReaderEnabled) return;
|
||||
|
||||
if (updatedComments.length >=
|
||||
_pageSize + _pageSize * state.currentPage &&
|
||||
updatedComments.length <=
|
||||
|
@ -3,6 +3,7 @@ export 'collapse/collapse_cubit.dart';
|
||||
export 'comments/comments_cubit.dart';
|
||||
export 'edit/edit_cubit.dart';
|
||||
export 'fav/fav_cubit.dart';
|
||||
export 'filter/filter_cubit.dart';
|
||||
export 'history/history_cubit.dart';
|
||||
export 'notification/notification_cubit.dart';
|
||||
export 'pin/pin_cubit.dart';
|
||||
|
40
lib/cubits/filter/filter_cubit.dart
Normal file
40
lib/cubits/filter/filter_cubit.dart
Normal file
@ -0,0 +1,40 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
|
||||
part 'filter_state.dart';
|
||||
|
||||
class FilterCubit extends Cubit<FilterState> {
|
||||
FilterCubit({PreferenceRepository? preferenceRepository})
|
||||
: _preferenceRepository =
|
||||
preferenceRepository ?? locator.get<PreferenceRepository>(),
|
||||
super(FilterState.init()) {
|
||||
init();
|
||||
}
|
||||
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
|
||||
void init() {
|
||||
_preferenceRepository.filterKeywords.then(
|
||||
(List<String> keywords) => emit(
|
||||
state.copyWith(
|
||||
keywords: keywords.toSet(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void addKeyword(String keyword) {
|
||||
final Set<String> updated = Set<String>.from(state.keywords)..add(keyword);
|
||||
emit(state.copyWith(keywords: updated));
|
||||
_preferenceRepository.updateFilterKeywords(updated.toList(growable: false));
|
||||
}
|
||||
|
||||
void removeKeyword(String keyword) {
|
||||
final Set<String> updated = Set<String>.from(state.keywords)
|
||||
..remove(keyword);
|
||||
emit(state.copyWith(keywords: updated));
|
||||
_preferenceRepository.updateFilterKeywords(updated.toList(growable: false));
|
||||
}
|
||||
}
|
20
lib/cubits/filter/filter_state.dart
Normal file
20
lib/cubits/filter/filter_state.dart
Normal file
@ -0,0 +1,20 @@
|
||||
part of 'filter_cubit.dart';
|
||||
|
||||
class FilterState extends Equatable {
|
||||
const FilterState({
|
||||
required this.keywords,
|
||||
});
|
||||
|
||||
FilterState.init() : keywords = <String>{};
|
||||
|
||||
final Set<String> keywords;
|
||||
|
||||
FilterState copyWith({Set<String>? keywords}) {
|
||||
return FilterState(
|
||||
keywords: keywords ?? this.keywords,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[keywords];
|
||||
}
|
@ -52,8 +52,6 @@ class PreferenceState extends Equatable {
|
||||
|
||||
bool get complexStoryTileEnabled => _isOn<DisplayModePreference>();
|
||||
|
||||
bool get webFirstEnabled => _isOn<NavigationModePreference>();
|
||||
|
||||
bool get eyeCandyEnabled => _isOn<EyeCandyModePreference>();
|
||||
|
||||
bool get trueDarkEnabled => _isOn<TrueDarkModePreference>();
|
||||
|
@ -16,8 +16,13 @@ class UserCubit extends Cubit<UserState> {
|
||||
|
||||
void init({required String userId}) {
|
||||
emit(state.copyWith(status: UserStatus.loading));
|
||||
_storiesRepository.fetchUser(id: userId).then((User user) {
|
||||
emit(state.copyWith(user: user, status: UserStatus.loaded));
|
||||
_storiesRepository.fetchUser(id: userId).then((User? user) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
user: user ?? User.emptyWithId(userId),
|
||||
status: UserStatus.loaded,
|
||||
),
|
||||
);
|
||||
}).onError((_, __) {
|
||||
emit(state.copyWith(status: UserStatus.failure));
|
||||
return;
|
||||
|
@ -6,8 +6,6 @@ import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
|
||||
extension ContextExtension on BuildContext {
|
||||
bool get isScreenReaderEnabled => MediaQuery.of(this).accessibleNavigation;
|
||||
|
||||
T? tryRead<T>() {
|
||||
try {
|
||||
return read<T>();
|
||||
@ -21,8 +19,6 @@ extension ContextExtension on BuildContext {
|
||||
VoidCallback? action,
|
||||
String? label,
|
||||
}) {
|
||||
if (isScreenReaderEnabled) return;
|
||||
|
||||
ScaffoldMessenger.of(this).showSnackBar(
|
||||
SnackBar(
|
||||
backgroundColor: Palette.deepOrange,
|
||||
|
@ -58,10 +58,12 @@ extension StateExtension on State {
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (BuildContext context) {
|
||||
return MorePopupMenu(
|
||||
item: item,
|
||||
isBlocked: isBlocked,
|
||||
onLoginTapped: onLoginTapped,
|
||||
return SafeArea(
|
||||
child: MorePopupMenu(
|
||||
item: item,
|
||||
isBlocked: isBlocked,
|
||||
onLoginTapped: onLoginTapped,
|
||||
),
|
||||
);
|
||||
},
|
||||
).then((MenuAction? action) {
|
||||
@ -106,24 +108,26 @@ extension StateExtension on State {
|
||||
linkToShare = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return Container(
|
||||
height: 140,
|
||||
color: Theme.of(context).canvasColor,
|
||||
child: Material(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
onTap: () => Navigator.pop(context, item.url),
|
||||
title: const Text('Link to article'),
|
||||
),
|
||||
ListTile(
|
||||
onTap: () => Navigator.pop(
|
||||
context,
|
||||
'https://news.ycombinator.com/item?id=${item.id}',
|
||||
return SafeArea(
|
||||
child: ColoredBox(
|
||||
color: Theme.of(context).canvasColor,
|
||||
child: Material(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
onTap: () => Navigator.pop(context, item.url),
|
||||
title: const Text('Link to article'),
|
||||
),
|
||||
title: const Text('Link to HN'),
|
||||
),
|
||||
],
|
||||
ListTile(
|
||||
onTap: () => Navigator.pop(
|
||||
context,
|
||||
'https://news.ycombinator.com/item?id=${item.id}',
|
||||
),
|
||||
title: const Text('Link to HN'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:adaptive_theme/adaptive_theme.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'package:feature_discovery/feature_discovery.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
@ -110,13 +111,27 @@ Future<void> main({bool testing = false}) async {
|
||||
},
|
||||
);
|
||||
} else if (Platform.isAndroid) {
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
statusBarColor: Palette.transparent,
|
||||
systemNavigationBarColor: Palette.transparent,
|
||||
systemNavigationBarDividerColor: Palette.transparent,
|
||||
),
|
||||
);
|
||||
final DeviceInfoPlugin deviceInfoPlugin = DeviceInfoPlugin();
|
||||
final AndroidDeviceInfo androidInfo = await deviceInfoPlugin.androidInfo;
|
||||
final int sdk = androidInfo.version.sdkInt;
|
||||
|
||||
if (sdk > 28) {
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
statusBarColor: Palette.transparent,
|
||||
systemNavigationBarColor: Palette.transparent,
|
||||
systemNavigationBarDividerColor: Palette.transparent,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
statusBarBrightness: Brightness.light,
|
||||
statusBarIconBrightness: Brightness.dark,
|
||||
statusBarColor: Colors.transparent,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
await SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.edgeToEdge,
|
||||
@ -168,9 +183,14 @@ class HackiApp extends StatelessWidget {
|
||||
lazy: false,
|
||||
create: (BuildContext context) => PreferenceCubit(),
|
||||
),
|
||||
BlocProvider<FilterCubit>(
|
||||
lazy: false,
|
||||
create: (BuildContext context) => FilterCubit(),
|
||||
),
|
||||
BlocProvider<StoriesBloc>(
|
||||
create: (BuildContext context) => StoriesBloc(
|
||||
preferenceCubit: context.read<PreferenceCubit>(),
|
||||
filterCubit: context.read<FilterCubit>(),
|
||||
),
|
||||
),
|
||||
BlocProvider<AuthBloc>(
|
||||
|
@ -15,6 +15,7 @@ class BuildableComment extends Comment with Buildable {
|
||||
required super.kids,
|
||||
required super.dead,
|
||||
required super.deleted,
|
||||
required super.hidden,
|
||||
required super.level,
|
||||
required this.elements,
|
||||
});
|
||||
@ -31,6 +32,7 @@ class BuildableComment extends Comment with Buildable {
|
||||
dead: comment.dead,
|
||||
deleted: comment.deleted,
|
||||
level: comment.level,
|
||||
hidden: comment.hidden,
|
||||
);
|
||||
|
||||
@override
|
||||
|
@ -17,6 +17,7 @@ class BuildableStory extends Story with Buildable {
|
||||
required super.type,
|
||||
required super.url,
|
||||
required super.parts,
|
||||
required super.hidden,
|
||||
required this.elements,
|
||||
});
|
||||
|
||||
@ -33,6 +34,7 @@ class BuildableStory extends Story with Buildable {
|
||||
type: story.type,
|
||||
url: story.url,
|
||||
parts: story.parts,
|
||||
hidden: story.hidden,
|
||||
);
|
||||
|
||||
BuildableStory.fromTitleOnlyStory(Story story)
|
||||
|
@ -11,6 +11,7 @@ class Comment extends Item {
|
||||
required super.kids,
|
||||
required super.dead,
|
||||
required super.deleted,
|
||||
required super.hidden,
|
||||
required this.level,
|
||||
}) : super(
|
||||
descendants: 0,
|
||||
@ -26,7 +27,10 @@ class Comment extends Item {
|
||||
|
||||
String get metadata => '''by $by $timeAgo''';
|
||||
|
||||
Comment copyWith({int? level}) {
|
||||
Comment copyWith({
|
||||
int? level,
|
||||
bool? hidden,
|
||||
}) {
|
||||
return Comment(
|
||||
id: id,
|
||||
time: time,
|
||||
@ -37,6 +41,7 @@ class Comment extends Item {
|
||||
kids: kids,
|
||||
dead: dead,
|
||||
deleted: deleted,
|
||||
hidden: hidden ?? this.hidden,
|
||||
level: level ?? this.level,
|
||||
);
|
||||
}
|
||||
|
@ -28,6 +28,7 @@ class Item extends Equatable {
|
||||
required this.type,
|
||||
required this.parts,
|
||||
required this.descendants,
|
||||
required this.hidden,
|
||||
});
|
||||
|
||||
Item.empty()
|
||||
@ -39,9 +40,10 @@ class Item extends Equatable {
|
||||
title = '',
|
||||
url = '',
|
||||
kids = <int>[],
|
||||
dead = false,
|
||||
parts = <int>[],
|
||||
dead = false,
|
||||
deleted = false,
|
||||
hidden = false,
|
||||
parent = 0,
|
||||
text = '',
|
||||
type = '';
|
||||
@ -60,7 +62,8 @@ class Item extends Equatable {
|
||||
deleted = json['deleted'] as bool? ?? false,
|
||||
parent = json['parent'] as int? ?? 0,
|
||||
parts = (json['parts'] as List<dynamic>?)?.cast<int>() ?? <int>[],
|
||||
type = json['type'] as String? ?? '';
|
||||
type = json['type'] as String? ?? '',
|
||||
hidden = json['hidden'] as bool? ?? false;
|
||||
|
||||
final int id;
|
||||
final int time;
|
||||
@ -73,6 +76,11 @@ class Item extends Equatable {
|
||||
final bool deleted;
|
||||
final bool dead;
|
||||
|
||||
/// Whether or not the item should be hidden.
|
||||
/// true if any of filter keywords set by user presents in [text]
|
||||
/// or [title].
|
||||
final bool hidden;
|
||||
|
||||
final String by;
|
||||
final String text;
|
||||
final String url;
|
||||
@ -128,5 +136,6 @@ class Item extends Equatable {
|
||||
type,
|
||||
parts,
|
||||
descendants,
|
||||
hidden,
|
||||
];
|
||||
}
|
||||
|
@ -20,6 +20,7 @@ class PollOption extends Item {
|
||||
descendants: 0,
|
||||
dead: false,
|
||||
deleted: false,
|
||||
hidden: false,
|
||||
);
|
||||
|
||||
PollOption.empty()
|
||||
|
@ -14,6 +14,7 @@ class Story extends Item {
|
||||
required super.text,
|
||||
required super.kids,
|
||||
required super.parts,
|
||||
required super.hidden,
|
||||
}) : super(
|
||||
dead: false,
|
||||
deleted: false,
|
||||
@ -38,10 +39,28 @@ class Story extends Item {
|
||||
parent: 0,
|
||||
text: '',
|
||||
type: '',
|
||||
hidden: false,
|
||||
);
|
||||
|
||||
Story.fromJson(super.json) : super.fromJson();
|
||||
|
||||
Story copyWith({bool? hidden}) {
|
||||
return Story(
|
||||
descendants: descendants,
|
||||
id: id,
|
||||
score: score,
|
||||
time: time,
|
||||
by: by,
|
||||
title: title,
|
||||
type: type,
|
||||
url: url,
|
||||
text: text,
|
||||
kids: kids,
|
||||
parts: parts,
|
||||
hidden: hidden ?? this.hidden,
|
||||
);
|
||||
}
|
||||
|
||||
String get metadata =>
|
||||
'''$score point${score > 1 ? 's' : ''} by $by $timeAgo | $descendants comment${descendants > 1 ? 's' : ''}''';
|
||||
|
||||
|
@ -17,7 +17,7 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
|
||||
static final List<Preference<dynamic>> allPreferences =
|
||||
UnmodifiableListView<Preference<dynamic>>(
|
||||
<Preference<dynamic>>[
|
||||
// Order of these first four preferences does not matter.
|
||||
// Order of these preferences does not matter.
|
||||
FetchModePreference(),
|
||||
CommentsOrderPreference(),
|
||||
FontPreference(),
|
||||
@ -31,7 +31,6 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
|
||||
const NotificationModePreference(),
|
||||
const SwipeGesturePreference(),
|
||||
const CollapseModePreference(),
|
||||
const NavigationModePreference(),
|
||||
const ReaderModePreference(),
|
||||
const MarkReadStoriesModePreference(),
|
||||
const EyeCandyModePreference(),
|
||||
@ -54,7 +53,6 @@ abstract class IntPreference extends Preference<int> {
|
||||
const bool _notificationModeDefaultValue = true;
|
||||
const bool _swipeGestureModeDefaultValue = false;
|
||||
const bool _displayModeDefaultValue = true;
|
||||
const bool _navigationModeDefaultValue = false;
|
||||
const bool _eyeCandyModeDefaultValue = false;
|
||||
const bool _trueDarkModeDefaultValue = false;
|
||||
const bool _readerModeDefaultValue = true;
|
||||
@ -189,29 +187,6 @@ class StoryUrlModePreference extends BooleanPreference {
|
||||
String get subtitle => '''show url in story tile.''';
|
||||
}
|
||||
|
||||
/// The value deciding whether or not user should be
|
||||
/// navigated to web view first. Defaults to false.
|
||||
class NavigationModePreference extends BooleanPreference {
|
||||
const NavigationModePreference({bool? val})
|
||||
: super(
|
||||
val: val ?? _navigationModeDefaultValue,
|
||||
);
|
||||
|
||||
@override
|
||||
NavigationModePreference copyWith({required bool? val}) {
|
||||
return NavigationModePreference(val: val);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'navigationMode';
|
||||
|
||||
@override
|
||||
String get title => 'Show Web Page First';
|
||||
|
||||
@override
|
||||
String get subtitle => '''show web page first after tapping on story.''';
|
||||
}
|
||||
|
||||
class ReaderModePreference extends BooleanPreference {
|
||||
const ReaderModePreference({bool? val})
|
||||
: super(val: val ?? _readerModeDefaultValue);
|
||||
|
@ -17,6 +17,12 @@ class User extends Equatable {
|
||||
id = '',
|
||||
karma = 0;
|
||||
|
||||
const User.emptyWithId(this.id)
|
||||
: about = '',
|
||||
created = 0,
|
||||
delay = 0,
|
||||
karma = 0;
|
||||
|
||||
User.fromJson(Map<String, dynamic> json)
|
||||
: about = json['about'] as String? ?? '',
|
||||
created = json['created'] as int? ?? 0,
|
||||
|
@ -22,6 +22,7 @@ class PreferenceRepository {
|
||||
static const String _usernameKey = 'username';
|
||||
static const String _passwordKey = 'password';
|
||||
static const String _blocklistKey = 'blocklist';
|
||||
static const String _filterKeywordsKey = 'filterKeywords';
|
||||
static const String _pinnedStoriesIdsKey = 'pinnedStoriesIds';
|
||||
static const String _unreadCommentsIdsKey = 'unreadCommentsIds';
|
||||
static const String _lastReadStoryIdKey = 'lastReadStoryId';
|
||||
@ -274,6 +275,20 @@ class PreferenceRepository {
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region filter
|
||||
|
||||
Future<List<String>> get filterKeywords async => _prefs.then(
|
||||
(SharedPreferences prefs) =>
|
||||
prefs.getStringList(_filterKeywordsKey) ?? <String>[],
|
||||
);
|
||||
|
||||
Future<void> updateFilterKeywords(List<String> keywords) async {
|
||||
final SharedPreferences prefs = await _prefs;
|
||||
await prefs.setStringList(_filterKeywordsKey, keywords);
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region pins
|
||||
|
||||
Future<List<int>> get pinnedStoriesIds async {
|
||||
|
@ -58,6 +58,7 @@ class SearchRepository {
|
||||
parent: parentId,
|
||||
dead: false,
|
||||
deleted: false,
|
||||
hidden: false,
|
||||
level: 0,
|
||||
);
|
||||
yield comment;
|
||||
@ -80,6 +81,7 @@ class SearchRepository {
|
||||
// response doesn't contain kids and parts.
|
||||
kids: const <int>[],
|
||||
parts: const <int>[],
|
||||
hidden: false,
|
||||
);
|
||||
yield story;
|
||||
}
|
||||
|
@ -74,11 +74,14 @@ class StoriesRepository {
|
||||
|
||||
/// Fetch a [User] by its [id].
|
||||
/// Hacker News uses user's username as [id].
|
||||
Future<User> fetchUser({required String id}) async {
|
||||
final User user = await _firebaseClient
|
||||
Future<User?> fetchUser({required String id}) async {
|
||||
final User? user = await _firebaseClient
|
||||
.get('${_baseUrl}user/$id.json')
|
||||
.then((dynamic val) {
|
||||
final Map<String, dynamic> json = val as Map<String, dynamic>;
|
||||
final Map<String, dynamic>? json = val as Map<String, dynamic>?;
|
||||
|
||||
if (json == null) return null;
|
||||
|
||||
final User user = User.fromJson(json);
|
||||
return user;
|
||||
});
|
||||
|
@ -92,14 +92,12 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
|
||||
SchedulerBinding.instance
|
||||
..addPostFrameCallback((_) {
|
||||
if (context.isScreenReaderEnabled == false) {
|
||||
FeatureDiscovery.discoverFeatures(
|
||||
context,
|
||||
<String>{
|
||||
Constants.featureLogIn,
|
||||
},
|
||||
);
|
||||
}
|
||||
FeatureDiscovery.discoverFeatures(
|
||||
context,
|
||||
<String>{
|
||||
Constants.featureLogIn,
|
||||
},
|
||||
);
|
||||
})
|
||||
..addPostFrameCallback((_) {
|
||||
final ModalRoute<dynamic>? route = ModalRoute.of(context);
|
||||
@ -212,12 +210,9 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
}
|
||||
|
||||
void onStoryTapped(Story story, {bool isPin = false}) {
|
||||
final bool showWebFirst =
|
||||
context.read<PreferenceCubit>().state.webFirstEnabled;
|
||||
final bool useReader = context.read<PreferenceCubit>().state.readerEnabled;
|
||||
final bool offlineReading =
|
||||
context.read<StoriesBloc>().state.isOfflineReading;
|
||||
final bool hasRead = isPin || context.read<StoriesBloc>().hasRead(story);
|
||||
final bool splitViewEnabled = context.read<SplitViewCubit>().state.enabled;
|
||||
|
||||
// If a story is a job story and it has a link to the job posting,
|
||||
@ -229,7 +224,6 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
} else {
|
||||
final ItemScreenArgs args = ItemScreenArgs(
|
||||
item: story,
|
||||
isScreenReaderEnabled: context.isScreenReaderEnabled,
|
||||
);
|
||||
|
||||
context.read<ReminderCubit>().updateLastReadStoryId(story.id);
|
||||
@ -248,7 +242,7 @@ class _HomeScreenState extends State<HomeScreen>
|
||||
}
|
||||
}
|
||||
|
||||
if (story.url.isNotEmpty && (isJobWithLink || (showWebFirst && !hasRead))) {
|
||||
if (story.url.isNotEmpty && isJobWithLink) {
|
||||
LinkUtil.launch(
|
||||
story.url,
|
||||
useReader: useReader,
|
||||
|
@ -23,13 +23,11 @@ class ItemScreenArgs extends Equatable {
|
||||
required this.item,
|
||||
this.onlyShowTargetComment = false,
|
||||
this.useCommentCache = false,
|
||||
this.isScreenReaderEnabled = false,
|
||||
this.targetComments,
|
||||
});
|
||||
|
||||
final Item item;
|
||||
final bool onlyShowTargetComment;
|
||||
final bool isScreenReaderEnabled;
|
||||
final List<Comment>? targetComments;
|
||||
|
||||
/// when a user is trying to view a sub-thread from a main thread, we don't
|
||||
@ -41,7 +39,6 @@ class ItemScreenArgs extends Equatable {
|
||||
List<Object?> get props => <Object?>[
|
||||
item,
|
||||
onlyShowTargetComment,
|
||||
isScreenReaderEnabled,
|
||||
targetComments,
|
||||
useCommentCache,
|
||||
];
|
||||
@ -67,6 +64,7 @@ class ItemScreen extends StatefulWidget {
|
||||
providers: <BlocProvider<dynamic>>[
|
||||
BlocProvider<CommentsCubit>(
|
||||
create: (BuildContext context) => CommentsCubit(
|
||||
filterCubit: context.read<FilterCubit>(),
|
||||
isOfflineReading:
|
||||
context.read<StoriesBloc>().state.isOfflineReading,
|
||||
item: args.item,
|
||||
@ -75,7 +73,6 @@ class ItemScreen extends StatefulWidget {
|
||||
context.read<PreferenceCubit>().state.fetchMode,
|
||||
defaultCommentsOrder:
|
||||
context.read<PreferenceCubit>().state.order,
|
||||
isScreenReaderEnabled: args.isScreenReaderEnabled,
|
||||
)..init(
|
||||
onlyShowTargetComment: args.onlyShowTargetComment,
|
||||
targetAncestors: args.targetComments,
|
||||
@ -110,6 +107,7 @@ class ItemScreen extends StatefulWidget {
|
||||
providers: <BlocProvider<dynamic>>[
|
||||
BlocProvider<CommentsCubit>(
|
||||
create: (BuildContext context) => CommentsCubit(
|
||||
filterCubit: context.read<FilterCubit>(),
|
||||
isOfflineReading:
|
||||
context.read<StoriesBloc>().state.isOfflineReading,
|
||||
item: args.item,
|
||||
@ -118,7 +116,6 @@ class ItemScreen extends StatefulWidget {
|
||||
context.read<PreferenceCubit>().state.fetchMode,
|
||||
defaultCommentsOrder:
|
||||
context.read<PreferenceCubit>().state.order,
|
||||
isScreenReaderEnabled: args.isScreenReaderEnabled,
|
||||
)..init(
|
||||
onlyShowTargetComment: args.onlyShowTargetComment,
|
||||
targetAncestors: args.targetComments,
|
||||
@ -178,16 +175,14 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
|
||||
|
||||
SchedulerBinding.instance
|
||||
..addPostFrameCallback((_) {
|
||||
if (context.isScreenReaderEnabled == false) {
|
||||
FeatureDiscovery.discoverFeatures(
|
||||
context,
|
||||
<String>{
|
||||
Constants.featurePinToTop,
|
||||
Constants.featureAddStoryToFavList,
|
||||
Constants.featureOpenStoryInWebView,
|
||||
},
|
||||
);
|
||||
}
|
||||
FeatureDiscovery.discoverFeatures(
|
||||
context,
|
||||
<String>{
|
||||
Constants.featurePinToTop,
|
||||
Constants.featureAddStoryToFavList,
|
||||
Constants.featureOpenStoryInWebView,
|
||||
},
|
||||
);
|
||||
})
|
||||
..addPostFrameCallback((_) {
|
||||
final ModalRoute<dynamic>? route = ModalRoute.of(context);
|
||||
@ -325,8 +320,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
|
||||
context.read<SplitViewCubit>().zoom,
|
||||
onFontSizeTap: onFontSizeTapped,
|
||||
fontSizeIconButtonKey: fontSizeIconButtonKey,
|
||||
isScreenReaderEnabled:
|
||||
context.isScreenReaderEnabled,
|
||||
),
|
||||
);
|
||||
},
|
||||
@ -362,7 +355,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
|
||||
scrollController: scrollController,
|
||||
onFontSizeTap: onFontSizeTapped,
|
||||
fontSizeIconButtonKey: fontSizeIconButtonKey,
|
||||
isScreenReaderEnabled: context.isScreenReaderEnabled,
|
||||
),
|
||||
body: MainView(
|
||||
scrollController: scrollController,
|
||||
@ -444,45 +436,45 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
|
||||
}
|
||||
|
||||
void onRightMoreTapped(Comment comment) {
|
||||
const double bottomSheetHeight = 140;
|
||||
|
||||
HapticFeedback.lightImpact();
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return Container(
|
||||
height: bottomSheetHeight,
|
||||
color: Theme.of(context).canvasColor,
|
||||
child: Material(
|
||||
color: Palette.transparent,
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.av_timer),
|
||||
title: const Text('View ancestors'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
onTimeMachineActivated(comment);
|
||||
},
|
||||
enabled:
|
||||
comment.level > 0 && !(comment.dead || comment.deleted),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.list),
|
||||
title: const Text('View in separate thread'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
goToItemScreen(
|
||||
args: ItemScreenArgs(
|
||||
item: comment,
|
||||
useCommentCache: true,
|
||||
),
|
||||
forceNewScreen: true,
|
||||
);
|
||||
},
|
||||
enabled: !(comment.dead || comment.deleted),
|
||||
),
|
||||
],
|
||||
return SafeArea(
|
||||
child: ColoredBox(
|
||||
color: Theme.of(context).canvasColor,
|
||||
child: Material(
|
||||
color: Palette.transparent,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
leading: const Icon(Icons.av_timer),
|
||||
title: const Text('View ancestors'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
onTimeMachineActivated(comment);
|
||||
},
|
||||
enabled:
|
||||
comment.level > 0 && !(comment.dead || comment.deleted),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.list),
|
||||
title: const Text('View in separate thread'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
goToItemScreen(
|
||||
args: ItemScreenArgs(
|
||||
item: comment,
|
||||
useCommentCache: true,
|
||||
),
|
||||
forceNewScreen: true,
|
||||
);
|
||||
},
|
||||
enabled: !(comment.dead || comment.deleted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
@ -16,7 +16,6 @@ class CustomAppBar extends AppBar {
|
||||
bool splitViewEnabled = false,
|
||||
VoidCallback? onZoomTap,
|
||||
bool? expanded,
|
||||
bool isScreenReaderEnabled = false,
|
||||
}) : super(
|
||||
elevation: Dimens.zero,
|
||||
actions: <Widget>[
|
||||
@ -38,20 +37,19 @@ class CustomAppBar extends AppBar {
|
||||
ScrollUpIconButton(
|
||||
scrollController: scrollController,
|
||||
),
|
||||
if (isScreenReaderEnabled == false)
|
||||
IconButton(
|
||||
key: fontSizeIconButtonKey,
|
||||
icon: Text(
|
||||
String.fromCharCode(FeatherIcons.type.codePoint),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: TextDimens.pt18,
|
||||
fontFamily: FeatherIcons.type.fontFamily,
|
||||
package: FeatherIcons.type.fontPackage,
|
||||
),
|
||||
IconButton(
|
||||
key: fontSizeIconButtonKey,
|
||||
icon: Text(
|
||||
String.fromCharCode(FeatherIcons.type.codePoint),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: TextDimens.pt18,
|
||||
fontFamily: FeatherIcons.type.fontFamily,
|
||||
package: FeatherIcons.type.fontPackage,
|
||||
),
|
||||
onPressed: onFontSizeTap,
|
||||
),
|
||||
onPressed: onFontSizeTap,
|
||||
),
|
||||
if (item is Story)
|
||||
PinIconButton(
|
||||
story: item,
|
||||
|
@ -23,9 +23,6 @@ class MorePopupMenu extends StatelessWidget {
|
||||
final bool isBlocked;
|
||||
final VoidCallback onLoginTapped;
|
||||
|
||||
static const double _storySheetHeight = 500;
|
||||
static const double _commentSheetHeight = 480;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider<VoteCubit>(
|
||||
@ -69,12 +66,12 @@ class MorePopupMenu extends StatelessWidget {
|
||||
builder: (BuildContext context, VoteState voteState) {
|
||||
final bool upvoted = voteState.vote == Vote.up;
|
||||
final bool downvoted = voteState.vote == Vote.down;
|
||||
return Container(
|
||||
height: item is Comment ? _commentSheetHeight : _storySheetHeight,
|
||||
return ColoredBox(
|
||||
color: Theme.of(context).canvasColor,
|
||||
child: Material(
|
||||
color: Palette.transparent,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
BlocProvider<UserCubit>(
|
||||
create: (BuildContext context) =>
|
||||
|
@ -232,6 +232,12 @@ class _SettingsState extends State<Settings> {
|
||||
onTap: showThemeSettingDialog,
|
||||
),
|
||||
const Divider(),
|
||||
ListTile(
|
||||
title: const Text(
|
||||
'Filter Keywords',
|
||||
),
|
||||
onTap: onFilterKeywordsTapped,
|
||||
),
|
||||
ListTile(
|
||||
title: const Text(
|
||||
'Export Favorites',
|
||||
@ -640,6 +646,100 @@ class _SettingsState extends State<Settings> {
|
||||
}
|
||||
}
|
||||
|
||||
void onFilterKeywordsTapped() {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text(
|
||||
'Filter Keywords',
|
||||
style: TextStyle(
|
||||
fontSize: TextDimens.pt16,
|
||||
),
|
||||
),
|
||||
content: BlocBuilder<FilterCubit, FilterState>(
|
||||
builder: (BuildContext context, FilterState state) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: <Widget>[
|
||||
if (state.keywords.isEmpty)
|
||||
const CenteredText(
|
||||
text:
|
||||
'''story or comment that contains keywords here will be hidden.''',
|
||||
),
|
||||
Wrap(
|
||||
spacing: Dimens.pt4,
|
||||
children: <Widget>[
|
||||
for (final String keyword in state.keywords)
|
||||
ActionChip(
|
||||
avatar: const Icon(
|
||||
Icons.close,
|
||||
size: TextDimens.pt14,
|
||||
),
|
||||
label: Text(keyword),
|
||||
onPressed: () => context
|
||||
.read<FilterCubit>()
|
||||
.removeKeyword(keyword),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: onAddKeywordTapped,
|
||||
child: const Text(
|
||||
'Add keyword',
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text(
|
||||
'Okay',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void onAddKeywordTapped() {
|
||||
final TextEditingController controller = TextEditingController();
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
content: TextField(
|
||||
autofocus: true,
|
||||
controller: controller,
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text(
|
||||
'Cancel',
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
final String keyword = controller.text.trim();
|
||||
if (keyword.isEmpty) return;
|
||||
context.read<FilterCubit>().addKeyword(keyword.toLowerCase());
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text(
|
||||
'Confirm',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> onExportFavoritesTapped() async {
|
||||
final List<int> allFavorites = context.read<FavCubit>().state.favIds;
|
||||
|
||||
|
@ -43,6 +43,7 @@ class CommentTile extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (comment.hidden) return const SizedBox.shrink();
|
||||
return BlocProvider<CollapseCubit>(
|
||||
key: ValueKey<String>('${comment.id}-BlocProvider'),
|
||||
lazy: false,
|
||||
@ -70,7 +71,6 @@ class CommentTile extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: <Widget>[
|
||||
Slidable(
|
||||
enabled: context.isScreenReaderEnabled == false,
|
||||
startActionPane: actionable
|
||||
? ActionPane(
|
||||
motion: const StretchMotion(),
|
||||
@ -118,14 +118,6 @@ class CommentTile extends StatelessWidget {
|
||||
: null,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
if (context.isScreenReaderEnabled) {
|
||||
onMoreTapped?.call(
|
||||
comment,
|
||||
context.rect,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (actionable) {
|
||||
HapticFeedback.selectionClick();
|
||||
context.read<CollapseCubit>().collapse();
|
||||
|
@ -38,17 +38,15 @@ class ItemText extends StatelessWidget {
|
||||
),
|
||||
onTap: onTap,
|
||||
textScaleFactor: MediaQuery.of(context).textScaleFactor,
|
||||
contextMenuBuilder: context.isScreenReaderEnabled
|
||||
? null
|
||||
: (
|
||||
BuildContext context,
|
||||
EditableTextState editableTextState,
|
||||
) =>
|
||||
contextMenuBuilder(
|
||||
context,
|
||||
editableTextState,
|
||||
item: item,
|
||||
),
|
||||
contextMenuBuilder: (
|
||||
BuildContext context,
|
||||
EditableTextState editableTextState,
|
||||
) =>
|
||||
contextMenuBuilder(
|
||||
context,
|
||||
editableTextState,
|
||||
item: item,
|
||||
),
|
||||
semanticsLabel: item.text,
|
||||
);
|
||||
} else {
|
||||
@ -59,17 +57,15 @@ class ItemText extends StatelessWidget {
|
||||
linkStyle: linkStyle,
|
||||
onOpen: (LinkableElement link) => LinkUtil.launch(link.url),
|
||||
onTap: onTap,
|
||||
contextMenuBuilder: context.isScreenReaderEnabled
|
||||
? null
|
||||
: (
|
||||
BuildContext context,
|
||||
EditableTextState editableTextState,
|
||||
) =>
|
||||
contextMenuBuilder(
|
||||
context,
|
||||
editableTextState,
|
||||
item: item,
|
||||
),
|
||||
contextMenuBuilder: (
|
||||
BuildContext context,
|
||||
EditableTextState editableTextState,
|
||||
) =>
|
||||
contextMenuBuilder(
|
||||
context,
|
||||
editableTextState,
|
||||
item: item,
|
||||
),
|
||||
semanticsLabel: item.text,
|
||||
);
|
||||
}
|
||||
|
@ -7,13 +7,13 @@ import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/widgets/link_preview/link_view.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class LinkPreview extends StatefulWidget {
|
||||
const LinkPreview({
|
||||
super.key,
|
||||
required this.link,
|
||||
required this.story,
|
||||
required this.onTap,
|
||||
required this.showMetadata,
|
||||
required this.showUrl,
|
||||
required this.isOfflineReading,
|
||||
@ -34,6 +34,7 @@ class LinkPreview extends StatefulWidget {
|
||||
});
|
||||
|
||||
final Story story;
|
||||
final VoidCallback onTap;
|
||||
|
||||
/// Web address (Url that need to be parsed)
|
||||
/// For IOS & Web, only HTTP and HTTPS are support
|
||||
@ -141,19 +142,6 @@ class _LinkPreviewState extends State<LinkPreview> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _launchURL(String url) async {
|
||||
final Uri uri = Uri.parse(url);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
await launchUrl(uri);
|
||||
} else {
|
||||
try {
|
||||
await launchUrl(uri);
|
||||
} catch (err) {
|
||||
throw Exception('Could not launch $url. Error: $err');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildLinkContainer(
|
||||
double height, {
|
||||
String? title = '',
|
||||
@ -184,7 +172,7 @@ class _LinkPreviewState extends State<LinkPreview> {
|
||||
description: desc ?? title ?? 'no comment yet.',
|
||||
imageUri: imageUri,
|
||||
imagePath: Constants.hackerNewsLogoPath,
|
||||
onTap: _launchURL,
|
||||
onTap: widget.onTap,
|
||||
titleTextStyle: widget.titleStyle,
|
||||
bodyTextOverflow: widget.bodyTextOverflow,
|
||||
bodyMaxLines: widget.bodyMaxLines,
|
||||
|
@ -5,7 +5,9 @@ import 'package:flutter/material.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/widgets/link_preview/models/models.dart';
|
||||
import 'package:hacki/screens/widgets/tap_down_wrapper.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/link_util.dart';
|
||||
|
||||
class LinkView extends StatelessWidget {
|
||||
LinkView({
|
||||
@ -41,7 +43,7 @@ class LinkView extends StatelessWidget {
|
||||
final String description;
|
||||
final String? imageUri;
|
||||
final String? imagePath;
|
||||
final void Function(String) onTap;
|
||||
final VoidCallback onTap;
|
||||
final TextStyle titleTextStyle;
|
||||
final bool showMultiMedia;
|
||||
final TextOverflow? bodyTextOverflow;
|
||||
@ -176,17 +178,26 @@ class LinkView extends StatelessWidget {
|
||||
titleStyle,
|
||||
);
|
||||
|
||||
return InkWell(
|
||||
onTap: () => onTap(url),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
if (showMultiMedia)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: 8,
|
||||
top: 5,
|
||||
bottom: 5,
|
||||
),
|
||||
return Row(
|
||||
children: <Widget>[
|
||||
if (showMultiMedia)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
right: 8,
|
||||
top: 5,
|
||||
bottom: 5,
|
||||
),
|
||||
child: TapDownWrapper(
|
||||
onTap: () {
|
||||
if (url.isNotEmpty) {
|
||||
LinkUtil.launch(
|
||||
url,
|
||||
useHackiForHnLink: false,
|
||||
);
|
||||
} else {
|
||||
onTap();
|
||||
}
|
||||
},
|
||||
child: SizedBox(
|
||||
height: layoutHeight,
|
||||
width: layoutHeight,
|
||||
@ -207,10 +218,13 @@ class LinkView extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: Dimens.pt5),
|
||||
SizedBox(
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: Dimens.pt5),
|
||||
TapDownWrapper(
|
||||
onTap: onTap,
|
||||
child: SizedBox(
|
||||
height: layoutHeight,
|
||||
width: layoutWidth - layoutHeight - 8,
|
||||
child: Column(
|
||||
@ -258,8 +272,8 @@ class LinkView extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -31,42 +31,39 @@ class StoryTile extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (story.hidden) return const SizedBox.shrink();
|
||||
if (showWebPreview) {
|
||||
final double height = context.storyTileHeight;
|
||||
return Semantics(
|
||||
label: story.screenReaderLabel,
|
||||
excludeSemantics: true,
|
||||
child: TapDownWrapper(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Dimens.pt12,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Dimens.pt12,
|
||||
),
|
||||
child: LinkPreview(
|
||||
story: story,
|
||||
link: story.url,
|
||||
isOfflineReading:
|
||||
context.read<StoriesBloc>().state.isOfflineReading,
|
||||
placeholderWidget: _LinkPreviewPlaceholder(
|
||||
height: height,
|
||||
),
|
||||
child: AbsorbPointer(
|
||||
child: LinkPreview(
|
||||
story: story,
|
||||
link: story.url,
|
||||
isOfflineReading:
|
||||
context.read<StoriesBloc>().state.isOfflineReading,
|
||||
placeholderWidget: _LinkPreviewPlaceholder(
|
||||
height: height,
|
||||
),
|
||||
errorImage: Constants.hackerNewsLogoLink,
|
||||
backgroundColor: Palette.transparent,
|
||||
borderRadius: Dimens.zero,
|
||||
removeElevation: true,
|
||||
bodyMaxLines: context.storyTileMaxLines,
|
||||
errorTitle: story.title,
|
||||
titleStyle: TextStyle(
|
||||
color: hasRead
|
||||
? Palette.grey[500]
|
||||
: Theme.of(context).textTheme.bodyLarge?.color,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
showMetadata: showMetadata,
|
||||
showUrl: showUrl,
|
||||
),
|
||||
errorImage: Constants.hackerNewsLogoLink,
|
||||
backgroundColor: Palette.transparent,
|
||||
borderRadius: Dimens.zero,
|
||||
removeElevation: true,
|
||||
bodyMaxLines: context.storyTileMaxLines,
|
||||
errorTitle: story.title,
|
||||
titleStyle: TextStyle(
|
||||
color: hasRead
|
||||
? Palette.grey[500]
|
||||
: Theme.of(context).textTheme.bodyLarge?.color,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
showMetadata: showMetadata,
|
||||
showUrl: showUrl,
|
||||
onTap: onTap,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
30
pubspec.lock
30
pubspec.lock
@ -13,10 +13,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: adaptive_theme
|
||||
sha256: "84af26cfc68220df3cd35d9d94cb8953e7182ef560e13d8efb87f32bf1e588fc"
|
||||
sha256: "61bde10390e937d11d05c6cf0d5cf378a73d49f9a442262e43613dae60ed0b3f"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
version: "3.2.0"
|
||||
analyzer:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -201,6 +201,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.8"
|
||||
device_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: device_info_plus
|
||||
sha256: "1d6e5a61674ba3a68fb048a7c7b4ff4bebfed8d7379dbe8f2b718231be9a7c95"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.1.0"
|
||||
device_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_platform_interface
|
||||
sha256: d3b01d5868b50ae571cd1dc6e502fc94d956b665756180f7b16ead09e836fd64
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.0"
|
||||
diff_match_patch:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -213,10 +229,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio
|
||||
sha256: "7d328c4d898a61efc3cd93655a0955858e29a0aa647f0f9e02d59b3bb275e2e8"
|
||||
sha256: "3e5c4a94d112540d0c9a6b7f3969832e1604eb8cde0f88d0808382f9f632100b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.6"
|
||||
version: "5.0.3"
|
||||
equatable:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -572,10 +588,10 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: logger
|
||||
sha256: "5076f09225f91dc49289a4ccb92df2eeea9ea01cf7c26d49b3a1f04c6a49eec1"
|
||||
sha256: db2ff852ed77090ba9f62d3611e4208a3d11dfa35991a81ae724c113fcb3e3f7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
version: "1.3.0"
|
||||
logging:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1359,4 +1375,4 @@ packages:
|
||||
version: "3.1.1"
|
||||
sdks:
|
||||
dart: ">=2.19.0 <3.0.0"
|
||||
flutter: ">=3.7.6"
|
||||
flutter: ">=3.7.10"
|
||||
|
11
pubspec.yaml
11
pubspec.yaml
@ -1,21 +1,22 @@
|
||||
name: hacki
|
||||
description: A Hacker News reader.
|
||||
version: 1.3.3+102
|
||||
version: 1.4.2+106
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
sdk: ">=2.17.0 <3.0.0"
|
||||
flutter: "3.7.6"
|
||||
flutter: "3.7.10"
|
||||
|
||||
dependencies:
|
||||
adaptive_theme: ^3.0.0
|
||||
adaptive_theme: ^3.2.0
|
||||
badges: ^3.0.2
|
||||
bloc: ^8.1.1
|
||||
cached_network_image: ^3.2.3
|
||||
clipboard: ^0.1.3
|
||||
collection: ^1.17.0
|
||||
connectivity_plus: ^3.0.2
|
||||
dio: ^4.0.6
|
||||
device_info_plus: ^8.1.0
|
||||
dio: ^5.0.3
|
||||
equatable: ^2.0.5
|
||||
fast_gbk: ^1.0.0
|
||||
feature_discovery:
|
||||
@ -44,7 +45,7 @@ dependencies:
|
||||
hydrated_bloc: ^9.1.0
|
||||
intl: ^0.18.0
|
||||
linkify: ^4.1.0
|
||||
logger: ^1.1.0
|
||||
logger: ^1.3.0
|
||||
memoize: ^3.0.0
|
||||
package_info_plus: ^3.0.3
|
||||
path: ^1.8.2
|
||||
|
Submodule submodules/flutter updated: 12cb4eb7a0...4b12645012
Reference in New Issue
Block a user