Compare commits

...

17 Commits

Author SHA1 Message Date
c7824eaef3 bump flutter to 3.7.2 (#134) 2023-02-08 17:43:23 -08:00
c2b66d29c3 add sharing option. (#131) 2023-02-04 18:46:04 -08:00
e0a53e44b2 bump flutter to 3.7.1 (#129) 2023-02-01 15:19:06 -08:00
4cf8379db0 fix Story model. (#128) 2023-01-31 22:02:17 -08:00
c1c26bf0e0 fix preference model. (#127) 2023-01-31 18:19:34 -08:00
29e2f4163d fix offline mode. (#126) 2023-01-31 16:54:28 -08:00
c3de80015d fix PinnedStories (#125) 2023-01-31 16:36:58 -08:00
436cd9ce8b fix Item model. (#123) 2023-01-31 15:56:29 -08:00
efb326be68 refactor models. (#122) 2023-01-30 23:43:12 -08:00
047903fe24 refactor. (#121) 2023-01-30 22:46:29 -08:00
41068ddf89 cleanup. (#120) 2023-01-29 21:34:54 -08:00
196516ce85 fix logger. (#119) 2023-01-29 20:55:46 -08:00
7f647b127d enable swipe gesture. (#118) 2023-01-29 20:03:11 -08:00
a50a0874e7 fix logger. (#117) 2023-01-29 18:46:55 -08:00
b176be96fb Allow customizing tab bar. (#112) 2023-01-29 16:48:08 -08:00
1e5af07691 improve UX. (#110) 2023-01-25 21:47:05 -08:00
ecf8c902dc bump flutter and linter version. (#108) 2023-01-25 12:33:06 -08:00
89 changed files with 3049 additions and 2261 deletions

View File

@ -12,12 +12,12 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
FLUTTER_VERSION: "3.3.10"
FLUTTER_VERSION: "3.7.2"
steps:
- uses: actions/checkout@v2
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.3.10'
flutter-version: '3.7.2'
channel: 'stable'
- run: flutter pub get
- run: flutter format --set-exit-if-changed .

View File

@ -31,7 +31,7 @@ jobs:
uses: subosito/flutter-action@v2
with:
cache: true
flutter-version: 3.3.10
flutter-version: 3.7.2
- run: flutter pub get
- run: flutter format --set-exit-if-changed .
- run: flutter analyze

View File

@ -1,4 +1,4 @@
include: package:very_good_analysis/analysis_options.2.4.0.yaml
include: package:very_good_analysis/analysis_options.3.1.0.yaml
linter:
rules:
parameter_assignments: false

View File

@ -1,4 +1,4 @@
include: package:very_good_analysis/analysis_options.2.4.0.yaml
include: package:very_good_analysis/analysis_options.3.1.0.yaml
linter:
rules:
parameter_assignments: false

View File

@ -0,0 +1,3 @@
- Customization of tab bar.
- Option to enable swipe gesture for switching between tabs.
- Access to action menu from home screen.

View File

@ -3,6 +3,8 @@ PODS:
- Flutter
- ReachabilitySwift
- Flutter (1.0.0)
- flutter_email_sender (0.0.1):
- Flutter
- flutter_inappwebview (0.0.1):
- Flutter
- flutter_inappwebview/Core (= 0.0.1)
@ -12,7 +14,7 @@ PODS:
- OrderedSet (~> 5.0)
- flutter_local_notifications (0.0.1):
- Flutter
- flutter_secure_storage (3.3.1):
- flutter_secure_storage (6.0.0):
- Flutter
- flutter_siri_suggestions (0.0.1):
- Flutter
@ -24,15 +26,17 @@ PODS:
- OrderedSet (5.0.0)
- package_info_plus (0.4.5):
- Flutter
- path_provider_ios (0.0.1):
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- ReachabilitySwift (5.0.0)
- receive_sharing_intent (0.0.1):
- Flutter
- share_plus (0.0.1):
- Flutter
- shared_preferences_ios (0.0.1):
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite (0.0.2):
- Flutter
- FMDB (>= 2.7.5)
@ -50,16 +54,17 @@ PODS:
DEPENDENCIES:
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
- Flutter (from `Flutter`)
- flutter_email_sender (from `.symlinks/plugins/flutter_email_sender/ios`)
- flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- flutter_siri_suggestions (from `.symlinks/plugins/flutter_siri_suggestions/ios`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/ios`)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/ios`)
- sqflite (from `.symlinks/plugins/sqflite/ios`)
- synced_shared_preferences (from `.symlinks/plugins/synced_shared_preferences/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
@ -78,6 +83,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/connectivity_plus/ios"
Flutter:
:path: Flutter
flutter_email_sender:
:path: ".symlinks/plugins/flutter_email_sender/ios"
flutter_inappwebview:
:path: ".symlinks/plugins/flutter_inappwebview/ios"
flutter_local_notifications:
@ -90,14 +97,14 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/integration_test/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_ios:
:path: ".symlinks/plugins/path_provider_ios/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/ios"
receive_sharing_intent:
:path: ".symlinks/plugins/receive_sharing_intent/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_ios:
:path: ".symlinks/plugins/shared_preferences_ios/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/ios"
sqflite:
:path: ".symlinks/plugins/sqflite/ios"
synced_shared_preferences:
@ -114,22 +121,23 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
connectivity_plus: 413a8857dd5d9f1c399a39130850d02fe0feaf7e
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
flutter_email_sender: 02d7443217d8c41483223627972bfdc09f74276b
flutter_inappwebview: bfd58618f49dc62f2676de690fc6dcda1d6c3721
flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743
flutter_secure_storage: 7953c38a04c3fdbb00571bcd87d8e3b5ceb9daec
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
flutter_siri_suggestions: 226fb7ef33d25d3fe0d4aa2a8bcf4b72730c466f
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
integration_test: a1e7d09bd98eca2fc37aefd79d4f41ad37bdbbe5
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
path_provider_foundation: 37748e03f12783f9de2cb2c4eadfaa25fe6d4852
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68
shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad
shared_preferences_foundation: 297b3ebca31b34ec92be11acd7fb0ba932c822ca
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
synced_shared_preferences: f722742b06d65c7315b8e9f56b794c9fbd5597f7
url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de
url_launcher_ios: ae1517e5e344f5544fb090b079e11f399dfbe4d2
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
webview_flutter_wkwebview: b7e70ef1ddded7e69c796c7390ee74180182971f
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6

View File

@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 51;
objectVersion = 54;
objects = {
/* Begin PBXBuildFile section */
@ -360,6 +360,7 @@
/* Begin PBXShellScriptBuildPhase section */
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
@ -413,6 +414,7 @@
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);

View File

@ -1,9 +1,9 @@
import UIKit
import Flutter
import workmanager
import shared_preferences_ios
import shared_preferences_foundation
import flutter_secure_storage
import path_provider_ios
import path_provider_foundation
import flutter_local_notifications
@UIApplicationMain
@ -26,8 +26,8 @@ import flutter_local_notifications
WorkmanagerPlugin.setPluginRegistrantCallback { registry in
GeneratedPluginRegistrant.register(with: registry)
FLTSharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin")!)
FLTPathProviderPlugin.register(with: registry.registrar(forPlugin: "io.flutter.plugins.pathprovider.PathProviderPlugin")!)
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin")!)
PathProviderPlugin.register(with: registry.registrar(forPlugin: "io.flutter.plugins.pathprovider.PathProviderPlugin")!)
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "com.dexterous.flutterlocalnotifications.FlutterLocalNotificationsPlugin")!)
}

View File

@ -74,5 +74,7 @@
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
</dict>
</plist>

View File

@ -37,6 +37,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
on<StoryRead>(onStoryRead);
on<StoriesLoaded>(onStoriesLoaded);
on<StoriesDownload>(onDownload);
on<StoriesCancelDownload>(onCancelDownload);
on<StoryDownloaded>(onStoryDownloaded);
on<StoriesExitOffline>(onExitOffline);
on<StoriesPageSizeChanged>(onPageSizeChanged);
@ -70,7 +71,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
) async {
_streamSubscription ??=
_preferenceCubit.stream.listen((PreferenceState event) {
final bool isComplexTile = event.showComplexStoryTile;
final bool isComplexTile = event.complexStoryTileEnabled;
final int pageSize = _getPageSize(isComplexTile: isComplexTile);
if (pageSize != state.currentPageSize) {
@ -78,12 +79,17 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
}
});
final bool hasCachedStories = await _offlineRepository.hasCachedStories;
final bool isComplexTile = _preferenceCubit.state.showComplexStoryTile;
final bool isComplexTile = _preferenceCubit.state.complexStoryTileEnabled;
final int pageSize = _getPageSize(isComplexTile: isComplexTile);
emit(
const StoriesState.init().copyWith(
offlineReading: hasCachedStories,
offlineReading: hasCachedStories &&
// Only go into offline mode in the next session.
state.downloadStatus == StoriesDownloadStatus.initial,
currentPageSize: pageSize,
downloadStatus: state.downloadStatus,
storiesDownloaded: state.storiesDownloaded,
storiesToBeDownloaded: state.storiesToBeDownloaded,
),
);
for (final StoryType type in types) {
@ -296,12 +302,30 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
}
}
Future<void> onCancelDownload(
StoriesCancelDownload event,
Emitter<StoriesState> emit,
) async {
emit(
state.copyWith(
downloadStatus: StoriesDownloadStatus.canceled,
),
);
await _offlineRepository.deleteAllStoryIds();
await _offlineRepository.deleteAllStories();
await _offlineRepository.deleteAllComments();
}
Future<void> fetchAndCacheStories(
Iterable<int> ids, {
required bool includingWebPage,
required bool isPrioritized,
}) async {
for (final int id in ids) {
if (state.downloadStatus == StoriesDownloadStatus.canceled) break;
_logger.d('fetching story $id');
final Story? story = await _storiesRepository.fetchStoryBy(id);
if (story == null) {
@ -329,11 +353,13 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
.fetchAllChildrenComments(ids: story.kids)
.whereType<Comment>()
.listen(
(Comment comment) => unawaited(
_offlineRepository.cacheComment(comment: comment),
),
)
.onDone(() => add(StoryDownloaded(skipped: false)));
(Comment comment) {
_logger.d('fetched comment ${comment.id}');
unawaited(
_offlineRepository.cacheComment(comment: comment),
);
},
).onDone(() => add(StoryDownloaded(skipped: false)));
}
}
@ -374,7 +400,6 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
StoriesPageSizeChanged event,
Emitter<StoriesState> emit,
) async {
emit(const StoriesState.init());
add(StoriesInitialize());
}

View File

@ -46,6 +46,13 @@ class StoriesDownload extends StoriesEvent {
List<Object?> get props => <Object?>[includingWebPage];
}
class StoriesCancelDownload extends StoriesEvent {
StoriesCancelDownload();
@override
List<Object?> get props => <Object?>[];
}
class StoryDownloaded extends StoriesEvent {
StoryDownloaded({required this.skipped});

View File

@ -11,6 +11,7 @@ enum StoriesDownloadStatus {
downloading,
finished,
failure,
canceled,
}
class StoriesState extends Equatable {
@ -34,7 +35,6 @@ class StoriesState extends Equatable {
StoryType.latest: <Story>[],
StoryType.ask: <Story>[],
StoryType.show: <Story>[],
StoryType.jobs: <Story>[],
},
this.storyIdsByType = const <StoryType, List<int>>{
StoryType.top: <int>[],
@ -42,7 +42,6 @@ class StoriesState extends Equatable {
StoryType.latest: <int>[],
StoryType.ask: <int>[],
StoryType.show: <int>[],
StoryType.jobs: <int>[],
},
this.statusByType = const <StoryType, StoriesStatus>{
StoryType.top: StoriesStatus.initial,
@ -50,7 +49,6 @@ class StoriesState extends Equatable {
StoryType.latest: StoriesStatus.initial,
StoryType.ask: StoriesStatus.initial,
StoryType.show: StoriesStatus.initial,
StoryType.jobs: StoriesStatus.initial,
},
this.currentPageByType = const <StoryType, int>{
StoryType.top: 0,
@ -58,7 +56,6 @@ class StoriesState extends Equatable {
StoryType.latest: 0,
StoryType.ask: 0,
StoryType.show: 0,
StoryType.jobs: 0,
},
}) : offlineReading = false,
downloadStatus = StoriesDownloadStatus.initial,

View File

@ -1,3 +1,5 @@
import 'package:hacki/extensions/extensions.dart';
abstract class Constants {
static const String endUserAgreementLink =
'https://www.termsfeed.com/live/c1417f5c-a48b-4bd7-93b2-9cd4577bfc45';
@ -12,6 +14,9 @@ abstract class Constants {
static const String sponsorLink = 'https://github.com/sponsors/Livinglist';
static const String guidelineLink =
'https://news.ycombinator.com/newsguidelines.html';
static const String githubIssueLink =
'$githubLink/issues/new?title=Found+a+bug+in+Hacki&body=Please+describe+the+problem.';
static const String supportEmail = 'georgefung98@gmail.com';
static const String _imagePath = 'assets/images';
static const String hackerNewsLogoPath = '$_imagePath/hacker_news_logo.png';
@ -22,6 +27,8 @@ abstract class Constants {
'$_imagePath/comment_tile_right_slide.png';
static const String commentTileTopTapPath =
'$_imagePath/comment_tile_top_tap.png';
static const String logFilename = 'hacki_log.txt';
static const String previousLogFileName = 'old_hacki_log.txt';
/// Feature ids for feature discovery.
static const String featureAddStoryToFavList = 'add_story_to_fav_list';
@ -29,16 +36,16 @@ abstract class Constants {
static const String featureLogIn = 'log_in';
static const String featurePinToTop = 'pin_to_top';
static const List<String> happyFaces = <String>[
static final String happyFace = <String>[
'(๑•̀ㅂ•́)و✧',
'( ͡• ͜ʖ ͡•)',
'( ͡~ ͜ʖ ͡°)',
'٩(˘◡˘)۶',
'(─‿‿─)',
'(¬‿¬)',
];
].pickRandomly()!;
static const List<String> sadFaces = <String>[
static final String sadFace = <String>[
'ಥ_ಥ',
'(╯°□°)╯︵ ┻━┻',
r'¯\_(ツ)_/¯',
@ -48,7 +55,7 @@ abstract class Constants {
'(ㆆ_ㆆ)',
'ʕ•́ᴥ•̀ʔっ',
'(ㆆ_ㆆ)',
];
].pickRandomly()!;
}
abstract class RegExpConstants {

View File

@ -0,0 +1,41 @@
import 'dart:convert';
import 'dart:io';
import 'package:logger/logger.dart';
/// Writes the log output to a file.
/// Temporary solution to not being able to access
// ignore: comment_references
/// the original [FileOutput] from [Logger]
class CustomFileOutput extends LogOutput {
CustomFileOutput({
required this.file,
this.overrideExisting = false,
this.encoding = utf8,
});
final File file;
final bool overrideExisting;
final Encoding encoding;
IOSink? _sink;
@override
void init() {
_sink = file.openWrite(
mode: overrideExisting ? FileMode.writeOnly : FileMode.writeOnlyAppend,
encoding: encoding,
);
}
@override
void output(OutputEvent event) {
_sink?.writeAll(event.lines, '\n');
_sink?.writeln();
}
@override
Future<void> destroy() async {
await _sink?.flush();
await _sink?.close();
}
}

View File

@ -1,8 +1,11 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:hacki/config/custom_log_filter.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/utils/utils.dart';
import 'package:logger/logger.dart';
/// Global [GetIt.instance].
@ -10,8 +13,16 @@ final GetIt locator = GetIt.instance;
/// Set up [GetIt] locator.
Future<void> setUpLocator() async {
final File logOutputFile = await LogUtil.initLogFile();
locator
..registerSingleton<Logger>(Logger(filter: CustomLogFilter()))
..registerSingleton<Logger>(
Logger(
filter: CustomLogFilter(),
printer: LogUtil.logPrinter,
output: LogUtil.getLogOutput(logOutputFile),
),
)
..registerSingleton<StoriesRepository>(StoriesRepository())
..registerSingleton<PreferenceRepository>(PreferenceRepository())
..registerSingleton<SearchRepository>(SearchRepository())

View File

@ -72,7 +72,6 @@ class CommentsState extends Equatable {
@override
List<Object?> get props => <Object?>[
item,
comments,
status,
fetchParentStatus,
order,
@ -80,5 +79,6 @@ class CommentsState extends Equatable {
onlyShowTargetComment,
offlineReading,
currentPage,
comments,
];
}

View File

@ -13,6 +13,7 @@ export 'reminder/reminder_cubit.dart';
export 'search/search_cubit.dart';
export 'split_view/split_view_cubit.dart';
export 'submit/submit_cubit.dart';
export 'tab/tab_cubit.dart';
export 'time_machine/time_machine_cubit.dart';
export 'user/user_cubit.dart';
export 'vote/vote_cubit.dart';

View File

@ -42,9 +42,9 @@ class FavState extends Equatable {
@override
List<Object?> get props => <Object?>[
favIds,
favItems,
status,
currentPage,
favIds,
favItems,
];
}

View File

@ -42,9 +42,9 @@ class HistoryState extends Equatable {
@override
List<Object?> get props => <Object?>[
submittedIds,
submittedItems,
status,
currentPage,
submittedIds,
submittedItems,
];
}

View File

@ -30,16 +30,16 @@ class NotificationCubit extends Cubit<NotificationState> {
_authBloc.stream.listen((AuthState authState) {
if (authState.isLoggedIn && authState.username != _username) {
// Get the user setting.
if (_preferenceCubit.state.showNotification) {
if (_preferenceCubit.state.notificationEnabled) {
Future<void>.delayed(const Duration(seconds: 2), init);
}
// Listen for setting changes in the future.
_preferenceCubit.stream.listen((PreferenceState prefState) {
final bool isActive = _timer?.isActive ?? false;
if (prefState.showNotification && !isActive) {
if (prefState.notificationEnabled && !isActive) {
init();
} else if (!prefState.showNotification) {
} else if (!prefState.notificationEnabled) {
_timer?.cancel();
}
});
@ -126,7 +126,8 @@ class NotificationCubit extends Cubit<NotificationState> {
}
Future<void> refresh() async {
if (_authBloc.state.isLoggedIn && _preferenceCubit.state.showNotification) {
if (_authBloc.state.isLoggedIn &&
_preferenceCubit.state.notificationEnabled) {
emit(
state.copyWith(
status: NotificationStatus.loading,

View File

@ -77,11 +77,11 @@ class NotificationState extends Equatable {
@override
List<Object?> get props => <Object?>[
comments,
unreadCommentsIds,
allCommentsIds,
currentPage,
offset,
status,
comments,
unreadCommentsIds,
allCommentsIds,
];
}

View File

@ -4,18 +4,23 @@ import 'package:hacki/config/locator.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:logger/logger.dart';
part 'preference_state.dart';
class PreferenceCubit extends Cubit<PreferenceState> {
PreferenceCubit({PreferenceRepository? storageRepository})
: _preferenceRepository =
storageRepository ?? locator.get<PreferenceRepository>(),
PreferenceCubit({
PreferenceRepository? preferenceRepository,
Logger? logger,
}) : _preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(PreferenceState.init()) {
init();
}
final PreferenceRepository _preferenceRepository;
final Logger _logger;
void init() {
for (final BooleanPreference p
@ -32,6 +37,7 @@ class PreferenceCubit extends Cubit<PreferenceState> {
initPreference<int>(p).then<int?>((int? value) {
final Preference<dynamic> updatedPreference = p.copyWith(val: value);
emit(state.copyWithPreference(updatedPreference));
return null;
});
}
@ -50,17 +56,12 @@ class PreferenceCubit extends Cubit<PreferenceState> {
}
}
void toggle(BooleanPreference preference) {
final BooleanPreference updatedPreference =
preference.copyWith(val: !preference.val) as BooleanPreference;
emit(state.copyWithPreference(updatedPreference));
_preferenceRepository.setBool(preference.key, !preference.val);
}
void update<T>(Preference<T> preference, {required T to}) {
final T value = to;
final Preference<T> updatedPreference = preference.copyWith(val: value);
_logger.i('updating $preference to $value');
emit(state.copyWithPreference(updatedPreference));
switch (T) {

View File

@ -48,25 +48,44 @@ class PreferenceState extends Equatable {
.val;
}
bool get showNotification => _isOn<NotificationModePreference>();
bool get notificationEnabled => _isOn<NotificationModePreference>();
bool get showComplexStoryTile => _isOn<DisplayModePreference>();
bool get complexStoryTileEnabled => _isOn<DisplayModePreference>();
bool get showWebFirst => _isOn<NavigationModePreference>();
bool get webFirstEnabled => _isOn<NavigationModePreference>();
bool get showEyeCandy => _isOn<EyeCandyModePreference>();
bool get eyeCandyEnabled => _isOn<EyeCandyModePreference>();
bool get useTrueDark => _isOn<TrueDarkModePreference>();
bool get trueDarkEnabled => _isOn<TrueDarkModePreference>();
bool get useReader => _isOn<ReaderModePreference>();
bool get readerEnabled => _isOn<ReaderModePreference>();
bool get markReadStories => _isOn<MarkReadStoriesModePreference>();
bool get markReadStoriesEnabled => _isOn<MarkReadStoriesModePreference>();
bool get showMetadata => _isOn<MetadataModePreference>();
bool get metadataEnabled => _isOn<MetadataModePreference>();
bool get showUrl => _isOn<StoryUrlModePreference>();
bool get urlEnabled => _isOn<StoryUrlModePreference>();
bool get tapAnywhereToCollapse => _isOn<CollapseModePreference>();
bool get tapAnywhereToCollapseEnabled => _isOn<CollapseModePreference>();
bool get swipeGestureEnabled => _isOn<SwipeGesturePreference>();
List<StoryType> get tabs {
final String result =
preferences.singleWhereType<TabOrderPreference>().val.toString();
final List<int> tabIndexes = List<int>.generate(
result.length,
(int index) => result.codeUnitAt(index) - 48,
);
final List<StoryType> tabs = tabIndexes
.map((int index) => StoryType.values.elementAt(index))
.toList();
if (tabs.length < StoryType.values.length) {
tabs.insert(0, StoryType.values.first);
}
return tabs;
}
FetchMode get fetchMode => FetchMode.values
.elementAt(preferences.singleWhereType<FetchModePreference>().val);

View File

@ -0,0 +1,46 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
import 'package:logger/logger.dart';
part 'tab_state.dart';
class TabCubit extends Cubit<TabState> {
TabCubit({
required PreferenceCubit preferenceCubit,
Logger? logger,
}) : _preferenceCubit = preferenceCubit,
_logger = logger ?? locator.get<Logger>(),
super(TabState.init());
final PreferenceCubit _preferenceCubit;
final Logger _logger;
void init() {
final List<StoryType> tabs = _preferenceCubit.state.tabs;
_logger.i('updating tabs to $tabs');
emit(state.copyWith(tabs: tabs));
}
void update(int startIndex, int endIndex) {
_logger.d('updating ${state.tabs} by moving $startIndex to $endIndex');
final StoryType tab = state.tabs.elementAt(startIndex);
final List<StoryType> updatedTabs = List<StoryType>.from(state.tabs)
..insert(endIndex, tab)
..removeAt(startIndex < endIndex ? startIndex : startIndex + 1);
_logger.d(updatedTabs);
emit(state.copyWith(tabs: updatedTabs));
// Check to make sure there's no duplicate.
if (updatedTabs.toSet().length == StoryType.values.length) {
_preferenceCubit.update<int>(
TabOrderPreference(),
to: StoryType.convertToSettingsValue(updatedTabs),
);
}
}
}

View File

@ -0,0 +1,18 @@
part of 'tab_cubit.dart';
class TabState extends Equatable {
const TabState({required this.tabs});
TabState.init() : tabs = <StoryType>[];
final List<StoryType> tabs;
TabState copyWith({
List<StoryType>? tabs,
}) {
return TabState(tabs: tabs ?? this.tabs);
}
@override
List<Object?> get props => <Object?>[tabs];
}

View File

@ -22,12 +22,12 @@ extension ContextExtension on BuildContext {
static double _screenWidth = 0;
static double _storyTileHeight = 0;
static int _storyTileMaxLines = 4;
static const double _screenWidthLowerBound = 430,
_screenWidthUpperBound = 850,
_picHeightLowerBound = 110,
_picHeightUpperBound = 128,
_smallPicHeight = 100,
_picHeightFactor = 0.3;
static const double _screenWidthLowerBound = 430;
static const double _screenWidthUpperBound = 850;
static const double _picHeightLowerBound = 110;
static const double _picHeightUpperBound = 128;
static const double _smallPicHeight = 100;
static const double _picHeightFactor = 0.3;
double get storyTileHeight {
final double screenWidth =

View File

@ -1,7 +1,8 @@
import 'dart:developer' as dev;
import 'package:hacki/config/locator.dart';
import 'package:logger/logger.dart';
extension ObjectExtension on Object {
void log({String identifier = ''}) {
dev.log('$identifier ${toString()}', level: 2000);
locator.get<Logger>().d('$identifier ${toString()}');
}
}

View File

@ -1,9 +1,19 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/auth/auth_bloc.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/main.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/item/models/models.dart';
import 'package:hacki/screens/item/widgets/widgets.dart';
import 'package:hacki/screens/screens.dart' show ItemScreen, ItemScreenArgs;
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:share_plus/share_plus.dart';
extension StateExtension on State {
void showSnackBar({
@ -19,7 +29,7 @@ extension StateExtension on State {
? SnackBarAction(
label: label,
onPressed: action,
textColor: Theme.of(context).textTheme.bodyText1?.color,
textColor: Theme.of(context).textTheme.bodyLarge?.color,
)
: null,
behavior: SnackBarBehavior.floating,
@ -44,4 +54,203 @@ extension StateExtension on State {
return Future<void>.value();
}
void onMoreTapped(Item item, Rect? rect) {
HapticFeedback.lightImpact();
if (item.dead || item.deleted) {
return;
}
final bool isBlocked =
context.read<BlocklistCubit>().state.blocklist.contains(item.by);
showModalBottomSheet<MenuAction>(
context: context,
builder: (BuildContext context) {
return MorePopupMenu(
item: item,
isBlocked: isBlocked,
showSnackBar: showSnackBar,
onStoryLinkTapped: onStoryLinkTapped,
onLoginTapped: onLoginTapped,
);
},
).then((MenuAction? action) {
if (action != null) {
switch (action) {
case MenuAction.upvote:
break;
case MenuAction.downvote:
break;
case MenuAction.share:
onShareTapped(item, rect);
break;
case MenuAction.flag:
onFlagTapped(item);
break;
case MenuAction.block:
onBlockTapped(item, isBlocked: isBlocked);
break;
case MenuAction.cancel:
break;
}
}
});
}
Future<void> onStoryLinkTapped(String link) async {
final int? id = link.itemId;
if (id != null) {
await locator
.get<StoriesRepository>()
.fetchItemBy(id: id)
.then((Item? item) {
if (mounted) {
if (item != null) {
HackiApp.navigatorKey.currentState!.pushNamed(
ItemScreen.routeName,
arguments: ItemScreenArgs(item: item),
);
}
}
});
} else {
LinkUtil.launch(link);
}
}
Future<void> onShareTapped(Item item, Rect? rect) async {
late final String? linkToShare;
if (item.url.isNotEmpty) {
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}',
),
title: const Text('Link to HN'),
),
],
),
),
);
},
);
} else {
linkToShare = 'https://news.ycombinator.com/item?id=${item.id}';
}
if (linkToShare != null) {
await Share.share(
linkToShare,
sharePositionOrigin: rect,
);
}
}
void onFlagTapped(Item item) {
showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Flag this comment?'),
content: Text(
'Flag this comment posted by ${item.by}?',
style: const TextStyle(
color: Palette.grey,
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text(
'Cancel',
),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text(
'Yes',
),
),
],
);
},
).then((bool? yesTapped) {
if (yesTapped ?? false) {
context.read<AuthBloc>().add(AuthFlag(item: item));
showSnackBar(content: 'Comment flagged!');
}
});
}
void onBlockTapped(Item item, {required bool isBlocked}) {
showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text('${isBlocked ? 'Unblock' : 'Block'} this user?'),
content: Text(
'Do you want to ${isBlocked ? 'unblock' : 'block'} ${item.by}'
' and ${isBlocked ? 'display' : 'hide'} '
'comments posted by this user?',
style: const TextStyle(
color: Palette.grey,
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text(
'Cancel',
),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text(
'Yes',
),
),
],
);
},
).then((bool? yesTapped) {
if (yesTapped ?? false) {
if (isBlocked) {
context.read<BlocklistCubit>().removeFromBlocklist(item.by);
} else {
context.read<BlocklistCubit>().addToBlocklist(item.by);
}
showSnackBar(content: 'User ${isBlocked ? 'unblocked' : 'blocked'}!');
}
});
}
void onLoginTapped() {
final TextEditingController usernameController = TextEditingController();
final TextEditingController passwordController = TextEditingController();
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return LoginDialog(
usernameController: usernameController,
passwordController: passwordController,
showSnackBar: showSnackBar,
);
},
);
}
}

View File

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io';
import 'package:adaptive_theme/adaptive_theme.dart';
import 'package:equatable/equatable.dart';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -21,6 +22,7 @@ import 'package:hacki/services/fetcher.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hive/hive.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:logger/logger.dart';
import 'package:path_provider/path_provider.dart';
import 'package:rxdart/rxdart.dart' show BehaviorSubject;
import 'package:shared_preferences/shared_preferences.dart';
@ -36,11 +38,30 @@ final BehaviorSubject<String?> siriSuggestionSubject =
late final bool isTesting;
void notificationReceiver(NotificationResponse details) =>
selectNotificationSubject.add(details.payload);
Future<void> main({bool testing = false}) async {
WidgetsFlutterBinding.ensureInitialized();
isTesting = testing;
final Directory tempDir = await getTemporaryDirectory();
final String tempPath = tempDir.path;
Hive.init(tempPath);
await setUpLocator();
EquatableConfig.stringify = true;
FlutterError.onError = (FlutterErrorDetails details) {
locator.get<Logger>().e(
details.summary,
details.exceptionAsString(),
details.stack,
);
};
final HydratedStorage storage = await HydratedStorage.build(
storageDirectory: kIsWeb
? HydratedStorage.webStorageDirectory
@ -58,8 +79,8 @@ Future<void> main({bool testing = false}) async {
FlutterLocalNotificationsPlugin();
const AndroidInitializationSettings initializationSettingsAndroid =
AndroidInitializationSettings('@mipmap/ic_launcher');
const IOSInitializationSettings initializationSettingsIOS =
IOSInitializationSettings();
const DarwinInitializationSettings initializationSettingsIOS =
DarwinInitializationSettings();
const InitializationSettings initializationSettings =
InitializationSettings(
android: initializationSettingsAndroid,
@ -67,7 +88,8 @@ Future<void> main({bool testing = false}) async {
);
await flutterLocalNotificationsPlugin.initialize(
initializationSettings,
onSelectNotification: selectNotificationSubject.add,
onDidReceiveBackgroundNotificationResponse: notificationReceiver,
onDidReceiveNotificationResponse: notificationReceiver,
);
await flutterLocalNotificationsPlugin
.resolvePlatformSpecificImplementation<
@ -102,18 +124,13 @@ Future<void> main({bool testing = false}) async {
);
}
final Directory tempDir = await getTemporaryDirectory();
final String tempPath = tempDir.path;
Hive.init(tempPath);
await setUpLocator();
final AdaptiveThemeMode? savedThemeMode = await AdaptiveTheme.getThemeMode();
final SharedPreferences prefs = await SharedPreferences.getInstance();
final bool trueDarkMode =
prefs.getBool(const TrueDarkModePreference().key) ?? false;
Bloc.observer = CustomBlocObserver();
HydratedBloc.storage = storage;
runApp(
@ -201,6 +218,11 @@ class HackiApp extends StatelessWidget {
lazy: false,
create: (BuildContext context) => EditCubit(),
),
BlocProvider<TabCubit>(
create: (BuildContext context) => TabCubit(
preferenceCubit: context.read<PreferenceCubit>(),
)..init(),
)
],
child: AdaptiveTheme(
light: ThemeData(
@ -228,9 +250,9 @@ class HackiApp extends StatelessWidget {
return BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen:
(PreferenceState previous, PreferenceState current) =>
previous.useTrueDark != current.useTrueDark,
previous.trueDarkEnabled != current.trueDarkEnabled,
builder: (BuildContext context, PreferenceState prefState) {
final bool useTrueDark = prefState.useTrueDark &&
final bool useTrueDark = prefState.trueDarkEnabled &&
(mode == AdaptiveThemeMode.dark ||
(mode == AdaptiveThemeMode.system &&
SchedulerBinding

View File

@ -1,5 +1,3 @@
import 'dart:convert';
import 'package:hacki/models/item.dart';
class Comment extends Item {
@ -22,23 +20,7 @@ class Comment extends Item {
type: '',
);
Comment.fromJson(Map<String, dynamic> json, {this.level = 0})
: super(
id: json['id'] as int? ?? 0,
time: json['time'] as int? ?? 0,
by: json['by'] as String? ?? '',
text: json['text'] as String? ?? '',
kids: (json['kids'] as List<dynamic>?)?.cast<int>() ?? <int>[],
parent: json['parent'] as int? ?? 0,
deleted: json['deleted'] as bool? ?? false,
score: json['score'] as int? ?? 0,
descendants: 0,
dead: json['dead'] as bool? ?? false,
parts: <int>[],
title: '',
url: '',
type: '',
);
Comment.fromJson(super.json, {this.level = 0}) : super.fromJson();
final int level;
@ -74,11 +56,7 @@ class Comment extends Item {
};
@override
String toString() {
final String prettyString =
const JsonEncoder.withIndent(' ').convert(this);
return 'Comment $prettyString';
}
bool? get stringify => false;
@override
List<Object?> get props => <Object?>[

View File

@ -44,11 +44,11 @@ class Item extends Equatable {
title = json['title'] as String? ?? '',
text = json['text'] as String? ?? '',
url = json['url'] as String? ?? '',
kids = <int>[],
kids = (json['kids'] as List<dynamic>?)?.cast<int>() ?? <int>[],
dead = json['dead'] as bool? ?? false,
deleted = json['deleted'] as bool? ?? false,
parent = json['parent'] as int? ?? 0,
parts = <int>[],
parts = (json['parts'] as List<dynamic>?)?.cast<int>() ?? <int>[],
type = json['type'] as String? ?? '';
final int id;

View File

@ -9,4 +9,5 @@ export 'post_data.dart';
export 'preference.dart';
export 'search_params.dart';
export 'story.dart';
export 'story_type.dart';
export 'user.dart';

View File

@ -24,41 +24,11 @@ class PollOption extends Item {
PollOption.empty()
: ratio = 0,
super(
id: 0,
score: 0,
descendants: 0,
time: 0,
by: '',
title: '',
url: '',
kids: <int>[],
dead: false,
parts: <int>[],
deleted: false,
parent: 0,
text: '',
type: '',
);
super.empty();
PollOption.fromJson(Map<String, dynamic> json)
PollOption.fromJson(super.json)
: ratio = 0,
super(
descendants: 0,
id: json['id'] as int? ?? 0,
score: json['score'] as int? ?? 0,
time: json['time'] as int? ?? 0,
by: json['by'] as String? ?? '',
title: json['title'] as String? ?? '',
url: json['url'] as String? ?? '',
kids: <int>[],
text: json['text'] as String? ?? '',
dead: json['dead'] as bool? ?? false,
deleted: json['deleted'] as bool? ?? false,
type: json['type'] as String? ?? '',
parts: <int>[],
parent: 0,
);
super.fromJson();
final double ratio;

View File

@ -1,3 +1,4 @@
import 'dart:collection';
import 'dart:io';
import 'package:equatable/equatable.dart';
@ -13,22 +14,29 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
Preference<T> copyWith({required T? val});
static List<Preference<dynamic>> allPreferences = <Preference<dynamic>>[
FetchModePreference(),
CommentsOrderPreference(),
FontSizePreference(),
// order here reflects the order on settings screen.
const DisplayModePreference(),
const MetadataModePreference(),
const StoryUrlModePreference(),
const NotificationModePreference(),
const CollapseModePreference(),
NavigationModePreference(),
const ReaderModePreference(),
const MarkReadStoriesModePreference(),
const EyeCandyModePreference(),
const TrueDarkModePreference(),
];
static final List<Preference<dynamic>> allPreferences =
UnmodifiableListView<Preference<dynamic>>(
<Preference<dynamic>>[
// Order of these first four preferences does not matter.
FetchModePreference(),
CommentsOrderPreference(),
FontSizePreference(),
TabOrderPreference(),
// Order of items below matters and
// reflects the order on settings screen.
const DisplayModePreference(),
const MetadataModePreference(),
const StoryUrlModePreference(),
const NotificationModePreference(),
const SwipeGesturePreference(),
const CollapseModePreference(),
NavigationModePreference(),
const ReaderModePreference(),
const MarkReadStoriesModePreference(),
const EyeCandyModePreference(),
const TrueDarkModePreference(),
],
);
@override
List<Object?> get props => <Object?>[key];
@ -43,8 +51,9 @@ abstract class IntPreference extends Preference<int> {
}
const bool _notificationModeDefaultValue = true;
const bool _swipeGestureModeDefaultValue = false;
const bool _displayModeDefaultValue = true;
const bool _navigationModeDefaultValueIOS = true;
const bool _navigationModeDefaultValueIOS = false;
const bool _navigationModeDefaultValueAndroid = false;
const bool _eyeCandyModeDefaultValue = false;
const bool _trueDarkModeDefaultValue = false;
@ -52,10 +61,32 @@ const bool _readerModeDefaultValue = true;
const bool _markReadStoriesModeDefaultValue = true;
const bool _metadataModeDefaultValue = true;
const bool _storyUrlModeDefaultValue = true;
const bool _collapseModeDefaultValue = false;
const bool _collapseModeDefaultValue = true;
final int _fetchModeDefaultValue = FetchMode.eager.index;
final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
final int _fontSizeDefaultValue = FontSize.regular.index;
final int _tabOrderDefaultValue =
StoryType.convertToSettingsValue(StoryType.values);
class SwipeGesturePreference extends BooleanPreference {
const SwipeGesturePreference({bool? val})
: super(val: val ?? _swipeGestureModeDefaultValue);
@override
SwipeGesturePreference copyWith({required bool? val}) {
return SwipeGesturePreference(val: val);
}
@override
String get key => 'swipeGestureMode';
@override
String get title => 'Enable Swipe Gesture';
@override
String get subtitle =>
'''enable swipe gesture for switching between tabs. If enabled, long press on Story tile to trigger the action menu.''';
}
class NotificationModePreference extends BooleanPreference {
const NotificationModePreference({bool? val})
@ -91,6 +122,10 @@ class CollapseModePreference extends BooleanPreference {
@override
String get title => 'Tap Anywhere to Collapse';
@override
String get subtitle =>
'''if disabled, tap on the top of comment tile to collapse.''';
}
/// The value deciding whether or not the story
@ -304,3 +339,18 @@ class FontSizePreference extends IntPreference {
@override
String get title => 'Default font size';
}
class TabOrderPreference extends IntPreference {
TabOrderPreference({int? val}) : super(val: val ?? _tabOrderDefaultValue);
@override
TabOrderPreference copyWith({required int? val}) {
return TabOrderPreference(val: val);
}
@override
String get key => 'tabOrder';
@override
String get title => 'Tab order';
}

View File

@ -1,36 +1,6 @@
import 'package:hacki/config/constants.dart';
import 'package:hacki/models/item.dart';
enum StoryType {
top('topstories'),
best('beststories'),
latest('newstories'),
ask('askstories'),
show('showstories'),
jobs('jobstories');
const StoryType(this.path);
final String path;
String get label {
switch (this) {
case StoryType.top:
return 'TOP';
case StoryType.best:
return 'BEST';
case StoryType.latest:
return 'NEW';
case StoryType.ask:
return 'ASK';
case StoryType.show:
return 'SHOW';
case StoryType.jobs:
return 'JOBS';
}
}
}
class Story extends Item {
const Story({
required super.descendants,
@ -50,23 +20,7 @@ class Story extends Item {
parent: 0,
);
Story.empty()
: super(
id: 0,
score: 0,
descendants: 0,
time: 0,
by: '',
title: '',
url: '',
kids: <int>[],
dead: false,
parts: <int>[],
deleted: false,
parent: 0,
text: '',
type: '',
);
Story.empty() : super.empty();
Story.placeholder()
: super(
@ -86,23 +40,7 @@ class Story extends Item {
type: '',
);
Story.fromJson(Map<String, dynamic> json)
: super(
descendants: json['descendants'] as int? ?? 0,
id: json['id'] as int? ?? 0,
score: json['score'] as int? ?? 0,
time: json['time'] as int? ?? 0,
by: json['by'] as String? ?? '',
title: json['title'] as String? ?? '',
url: json['url'] as String? ?? '',
kids: (json['kids'] as List<dynamic>?)?.cast<int>() ?? <int>[],
text: json['text'] as String? ?? '',
dead: json['dead'] as bool? ?? false,
deleted: json['deleted'] as bool? ?? false,
type: json['type'] as String? ?? '',
parts: (json['parts'] as List<dynamic>?)?.cast<int>() ?? <int>[],
parent: 0,
);
Story.fromJson(super.json) : super.fromJson();
String get metadata =>
'''$score point${score > 1 ? 's' : ''} by $by $postedDate | $descendants comment${descendants > 1 ? 's' : ''}''';

View File

@ -0,0 +1,34 @@
enum StoryType {
top('topstories'),
best('beststories'),
latest('newstories'),
ask('askstories'),
show('showstories');
const StoryType(this.path);
final String path;
String get label {
switch (this) {
case StoryType.top:
return 'TOP';
case StoryType.best:
return 'BEST';
case StoryType.latest:
return 'NEW';
case StoryType.ask:
return 'ASK';
case StoryType.show:
return 'SHOW';
}
}
static int convertToSettingsValue(List<StoryType> tabs) {
return int.parse(
tabs
.map((StoryType e) => e.index.toString())
.reduce((String value, String element) => '$value$element'),
);
}
}

View File

@ -1,6 +1,5 @@
import 'dart:async';
import 'package:dio/dio.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/postable_repository.dart';
@ -9,13 +8,12 @@ import 'package:logger/logger.dart';
class AuthRepository extends PostableRepository {
AuthRepository({
Dio? dio,
super.dio,
PreferenceRepository? preferenceRepository,
Logger? logger,
}) : _preferenceRepository =
preferenceRepository ?? locator.get<PreferenceRepository>(),
_logger = logger ?? locator.get<Logger>(),
super(dio: dio);
_logger = logger ?? locator.get<Logger>();
final PreferenceRepository _preferenceRepository;
final Logger _logger;

View File

@ -8,10 +8,9 @@ import 'package:hacki/repositories/preference_repository.dart';
import 'package:hacki/utils/utils.dart';
class PostRepository extends PostableRepository {
PostRepository({Dio? dio, PreferenceRepository? storageRepository})
PostRepository({super.dio, PreferenceRepository? storageRepository})
: _preferenceRepository =
storageRepository ?? locator.get<PreferenceRepository>(),
super(dio: dio);
storageRepository ?? locator.get<PreferenceRepository>();
final PreferenceRepository _preferenceRepository;

View File

@ -0,0 +1,337 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart' hide Badge;
import 'package:flutter/scheduler.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_siri_suggestions/flutter_siri_suggestions.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/main.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/home/widgets/widgets.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:logger/logger.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:responsive_builder/responsive_builder.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
static const String routeName = '/';
static Route<dynamic> route() {
return MaterialPageRoute<HomeScreen>(
settings: const RouteSettings(name: routeName),
builder: (BuildContext context) => const HomeScreen(),
);
}
@override
_HomeScreenState createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen>
with SingleTickerProviderStateMixin, RouteAware {
late final TabController tabController;
late final StreamSubscription<String> intentDataStreamSubscription;
late final StreamSubscription<String?> notificationStreamSubscription;
late final StreamSubscription<String?> siriSuggestionStreamSubscription;
static final int tabLength = StoryType.values.length + 1;
@override
void didPopNext() {
super.didPopNext();
if (context.read<StoriesBloc>().deviceScreenType ==
DeviceScreenType.mobile) {
locator.get<Logger>().i('resetting comments in CommentCache');
Future<void>.delayed(
const Duration(milliseconds: 500),
locator.get<CommentCache>().resetComments,
);
}
}
@override
void initState() {
super.initState();
// This is for testing only.
// FeatureDiscovery.clearPreferences(context, <String>[
// Constants.featureLogIn,
// Constants.featureAddStoryToFavList,
// Constants.featureOpenStoryInWebView,
// Constants.featurePinToTop,
// ]);
ReceiveSharingIntent.getInitialText().then(onShareExtensionTapped);
intentDataStreamSubscription =
ReceiveSharingIntent.getTextStream().listen(onShareExtensionTapped);
if (!selectNotificationSubject.hasListener) {
notificationStreamSubscription =
selectNotificationSubject.stream.listen(onNotificationTapped);
}
if (!siriSuggestionSubject.hasListener) {
siriSuggestionStreamSubscription =
siriSuggestionSubject.stream.listen(onSiriSuggestionTapped);
}
SchedulerBinding.instance
..addPostFrameCallback((_) {
if (!isTesting) {
FeatureDiscovery.discoverFeatures(
context,
const <String>{
Constants.featureLogIn,
},
);
}
})
..addPostFrameCallback((_) {
final ModalRoute<dynamic>? route = ModalRoute.of(context);
if (route == null) return;
locator
.get<RouteObserver<ModalRoute<dynamic>>>()
.subscribe(this, route);
});
tabController = TabController(length: tabLength, vsync: this);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final DeviceScreenType deviceType =
getDeviceType(MediaQuery.of(context).size);
if (context.read<StoriesBloc>().deviceScreenType != deviceType) {
context.read<StoriesBloc>().deviceScreenType = deviceType;
context.read<StoriesBloc>().add(StoriesInitialize());
}
}
@override
void dispose() {
tabController.dispose();
intentDataStreamSubscription.cancel();
notificationStreamSubscription.cancel();
siriSuggestionStreamSubscription.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final BlocBuilder<PreferenceCubit, PreferenceState> homeScreen =
BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (PreferenceState previous, PreferenceState current) =>
previous.complexStoryTileEnabled != current.complexStoryTileEnabled ||
previous.metadataEnabled != current.metadataEnabled ||
previous.swipeGestureEnabled != current.swipeGestureEnabled,
builder: (BuildContext context, PreferenceState preferenceState) {
return DefaultTabController(
length: tabLength,
child: Scaffold(
resizeToAvoidBottomInset: false,
appBar: PreferredSize(
preferredSize: const Size(
Dimens.zero,
Dimens.pt40,
),
child: Column(
children: <Widget>[
SizedBox(
height: MediaQuery.of(context).padding.top - Dimens.pt8,
),
Theme(
data: ThemeData(
highlightColor: Palette.transparent,
splashColor: Palette.transparent,
primaryColor: Theme.of(context).primaryColor,
),
child: CustomTabBar(
tabController: tabController,
),
),
],
),
),
body: BlocBuilder<TabCubit, TabState>(
builder: (BuildContext context, TabState state) {
return TabBarView(
physics: preferenceState.swipeGestureEnabled
? const PageScrollPhysics()
: const NeverScrollableScrollPhysics(),
controller: tabController,
children: <Widget>[
for (final StoryType type in state.tabs)
StoriesListView(
key: ValueKey<StoryType>(type),
storyType: type,
header: PinnedStories(
preferenceState: preferenceState,
onStoryTapped: onStoryTapped,
),
onStoryTapped: onStoryTapped,
),
const ProfileScreen(),
],
);
},
),
),
);
},
);
return ScreenTypeLayout.builder(
mobile: (BuildContext context) {
context.read<SplitViewCubit>().disableSplitView();
return MobileHomeScreen(
homeScreen: homeScreen,
);
},
tablet: (BuildContext context) => TabletHomeScreen(
homeScreen: 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.offlineReading;
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,
// it would be better to just navigate to the web page.
final bool isJobWithLink = story.isJob && story.url.isNotEmpty;
if (isJobWithLink) {
context.read<ReminderCubit>().removeLastReadStoryId();
} else {
final ItemScreenArgs args = ItemScreenArgs(item: story);
context.read<ReminderCubit>().updateLastReadStoryId(story.id);
if (splitViewEnabled) {
context.read<SplitViewCubit>().updateItemScreenArgs(args);
} else {
HackiApp.navigatorKey.currentState
?.pushNamed(
ItemScreen.routeName,
arguments: args,
)
.whenComplete(() {
context.read<ReminderCubit>().removeLastReadStoryId();
});
}
}
if (story.url.isNotEmpty && (isJobWithLink || (showWebFirst && !hasRead))) {
LinkUtil.launch(
story.url,
useReader: useReader,
offlineReading: offlineReading,
);
}
context.read<StoriesBloc>().add(
StoryRead(
story: story,
),
);
if (Platform.isIOS) {
FlutterSiriSuggestions.instance.registerActivity(
FlutterSiriActivity(
story.title,
story.id.toString(),
suggestedInvocationPhrase: '',
contentDescription: story.text,
persistentIdentifier: story.id.toString(),
),
);
}
}
void onShareExtensionTapped(String? event) {
if (event == null) return;
final int? id = event.itemId;
if (id != null) {
locator.get<StoriesRepository>().fetchItemBy(id: id).then((Item? item) {
if (mounted) {
if (item != null) {
goToItemScreen(
args: ItemScreenArgs(item: item),
forceNewScreen: true,
);
}
}
});
}
}
Future<void> onSiriSuggestionTapped(String? id) async {
if (id == null) return;
final int? storyId = int.tryParse(id);
if (storyId == null) return;
await locator
.get<StoriesRepository>()
.fetchStoryBy(storyId)
.then((Story? story) {
if (story == null) {
showSnackBar(content: 'Something went wrong...');
return;
}
final ItemScreenArgs args = ItemScreenArgs(item: story);
goToItemScreen(args: args);
});
}
Future<void> onNotificationTapped(String? payload) async {
if (payload == null) return;
final Map<String, dynamic> payloadJson =
jsonDecode(payload) as Map<String, dynamic>;
final int? storyId = payloadJson['storyId'] as int?;
final int? commentId = payloadJson['commentId'] as int?;
if (storyId != null && commentId != null) {
context.read<NotificationCubit>().markAsRead(commentId);
await locator
.get<StoriesRepository>()
.fetchStoryBy(storyId)
.then((Story? story) {
if (story == null) {
showSnackBar(content: 'Something went wrong...');
return;
}
final ItemScreenArgs args = ItemScreenArgs(item: story);
goToItemScreen(args: args);
});
}
}
}

View File

@ -0,0 +1,31 @@
import 'package:flutter/material.dart' hide Badge;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
class MobileHomeScreen extends StatelessWidget {
const MobileHomeScreen({
super.key,
required this.homeScreen,
});
final Widget homeScreen;
@override
Widget build(BuildContext context) {
return Stack(
children: <Widget>[
Positioned.fill(child: homeScreen),
if (!context.read<ReminderCubit>().state.hasShown)
const Positioned(
left: Dimens.pt24,
right: Dimens.pt24,
bottom: Dimens.pt36,
height: Dimens.pt40,
child: CountdownReminder(),
),
],
);
}
}

View File

@ -0,0 +1,72 @@
import 'package:flutter/material.dart' hide Badge;
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
class PinnedStories extends StatelessWidget {
const PinnedStories({
super.key,
required this.preferenceState,
required this.onStoryTapped,
});
final PreferenceState preferenceState;
final void Function(Story story, {bool isPin}) onStoryTapped;
@override
Widget build(BuildContext context) {
return BlocBuilder<PinCubit, PinState>(
builder: (BuildContext context, PinState state) {
return Column(
children: <Widget>[
for (final Story story in state.pinnedStories)
FadeIn(
child: Slidable(
startActionPane: ActionPane(
motion: const BehindMotion(),
children: <Widget>[
SlidableAction(
onPressed: (_) {
HapticFeedback.lightImpact();
context.read<PinCubit>().unpinStory(story);
},
backgroundColor: Palette.red,
foregroundColor: Palette.white,
icon: preferenceState.complexStoryTileEnabled
? Icons.close
: null,
label: 'Unpin',
),
],
),
child: ColoredBox(
color: Palette.orangeAccent.withOpacity(0.2),
child: StoryTile(
key: ValueKey<String>('${story.id}-PinnedStoryTile'),
story: story,
onTap: () => onStoryTapped(story, isPin: true),
showWebPreview: preferenceState.complexStoryTileEnabled,
showMetadata: preferenceState.metadataEnabled,
showUrl: preferenceState.urlEnabled,
),
),
),
),
if (state.pinnedStories.isNotEmpty)
const Padding(
padding: EdgeInsets.symmetric(horizontal: Dimens.pt12),
child: Divider(
color: Palette.orangeAccent,
),
),
],
);
},
);
}
}

View File

@ -0,0 +1,92 @@
import 'package:flutter/material.dart' hide Badge;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:responsive_builder/responsive_builder.dart';
class TabletHomeScreen extends StatelessWidget {
const TabletHomeScreen({
super.key,
required this.homeScreen,
});
final Widget homeScreen;
@override
Widget build(BuildContext context) {
return ResponsiveBuilder(
builder: (BuildContext context, SizingInformation sizeInfo) {
context.read<SplitViewCubit>().enableSplitView();
double homeScreenWidth = 428;
if (sizeInfo.screenSize.width < homeScreenWidth * 2) {
homeScreenWidth = 345;
}
return BlocBuilder<SplitViewCubit, SplitViewState>(
buildWhen: (SplitViewState previous, SplitViewState current) =>
previous.expanded != current.expanded,
builder: (BuildContext context, SplitViewState state) {
return Stack(
children: <Widget>[
AnimatedPositioned(
left: Dimens.zero,
top: Dimens.zero,
bottom: Dimens.zero,
width: homeScreenWidth,
duration: const Duration(milliseconds: 300),
curve: Curves.elasticOut,
child: homeScreen,
),
Positioned(
left: Dimens.pt24,
bottom: Dimens.pt36,
height: Dimens.pt40,
width: homeScreenWidth - Dimens.pt24,
child: const CountdownReminder(),
),
AnimatedPositioned(
right: Dimens.zero,
top: Dimens.zero,
bottom: Dimens.zero,
left: state.expanded ? Dimens.zero : homeScreenWidth,
duration: const Duration(milliseconds: 300),
curve: Curves.elasticOut,
child: const _TabletStoryView(),
),
],
);
},
);
},
);
}
}
class _TabletStoryView extends StatelessWidget {
const _TabletStoryView();
@override
Widget build(BuildContext context) {
return BlocBuilder<SplitViewCubit, SplitViewState>(
buildWhen: (SplitViewState previous, SplitViewState current) =>
previous.itemScreenArgs != current.itemScreenArgs,
builder: (BuildContext context, SplitViewState state) {
if (state.itemScreenArgs != null) {
return ItemScreen.build(context, state.itemScreenArgs!);
}
return Material(
child: ColoredBox(
color: Theme.of(context).canvasColor,
child: const Center(
child: Text('Tap on story tile to view comments.'),
),
),
);
},
);
}
}

View File

@ -0,0 +1,3 @@
export 'mobile_home_screen.dart';
export 'pinned_stories.dart';
export 'tablet_home_screen.dart';

View File

@ -1,618 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:badges/badges.dart';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:flutter_siri_suggestions/flutter_siri_suggestions.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/main.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:logger/logger.dart';
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
import 'package:responsive_builder/responsive_builder.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
static const String routeName = '/';
static Route<dynamic> route() {
return MaterialPageRoute<HomeScreen>(
settings: const RouteSettings(name: routeName),
builder: (BuildContext context) => const HomeScreen(),
);
}
@override
_HomeScreenState createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen>
with SingleTickerProviderStateMixin, RouteAware {
final Throttle featureDiscoveryDismissThrottle = Throttle(
delay: _throttleDelay,
);
late final TabController tabController;
late final StreamSubscription<String> intentDataStreamSubscription;
late final StreamSubscription<String?> notificationStreamSubscription;
late final StreamSubscription<String?> siriSuggestionStreamSubscription;
int currentIndex = 0;
static const Duration _throttleDelay = Duration(seconds: 1);
@override
void didPopNext() {
super.didPopNext();
if (context.read<StoriesBloc>().deviceScreenType ==
DeviceScreenType.mobile) {
locator.get<Logger>().i('resetting comments in CommentCache');
Future<void>.delayed(
const Duration(milliseconds: 500),
locator.get<CommentCache>().resetComments,
);
}
}
@override
void initState() {
super.initState();
// This is for testing only.
// FeatureDiscovery.clearPreferences(context, <String>[
// Constants.featureLogIn,
// Constants.featureAddStoryToFavList,
// Constants.featureOpenStoryInWebView,
// Constants.featurePinToTop,
// ]);
ReceiveSharingIntent.getInitialText().then(onShareExtensionTapped);
intentDataStreamSubscription =
ReceiveSharingIntent.getTextStream().listen(onShareExtensionTapped);
if (!selectNotificationSubject.hasListener) {
notificationStreamSubscription =
selectNotificationSubject.stream.listen(onNotificationTapped);
}
if (!siriSuggestionSubject.hasListener) {
siriSuggestionStreamSubscription =
siriSuggestionSubject.stream.listen(onSiriSuggestionTapped);
}
SchedulerBinding.instance
..addPostFrameCallback((_) {
if (!isTesting) {
FeatureDiscovery.discoverFeatures(
context,
const <String>{
Constants.featureLogIn,
},
);
}
})
..addPostFrameCallback((_) {
final ModalRoute<dynamic>? route = ModalRoute.of(context);
if (route == null) return;
locator
.get<RouteObserver<ModalRoute<dynamic>>>()
.subscribe(this, route);
});
tabController = TabController(vsync: this, length: 6)
..addListener(() {
setState(() {
currentIndex = tabController.index;
});
});
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final DeviceScreenType deviceType =
getDeviceType(MediaQuery.of(context).size);
if (context.read<StoriesBloc>().deviceScreenType != deviceType) {
context.read<StoriesBloc>().deviceScreenType = deviceType;
context.read<StoriesBloc>().add(StoriesInitialize());
}
}
@override
void dispose() {
featureDiscoveryDismissThrottle.dispose();
tabController.dispose();
intentDataStreamSubscription.cancel();
notificationStreamSubscription.cancel();
siriSuggestionStreamSubscription.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final BlocBuilder<PreferenceCubit, PreferenceState> homeScreen =
BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (PreferenceState previous, PreferenceState current) =>
previous.showComplexStoryTile != current.showComplexStoryTile ||
previous.showMetadata != current.showMetadata,
builder: (BuildContext context, PreferenceState preferenceState) {
final BlocBuilder<PinCubit, PinState> pinnedStories =
BlocBuilder<PinCubit, PinState>(
builder: (BuildContext context, PinState state) {
return Column(
children: <Widget>[
for (final Story story in state.pinnedStories)
FadeIn(
child: Slidable(
startActionPane: ActionPane(
motion: const BehindMotion(),
children: <Widget>[
SlidableAction(
onPressed: (_) {
HapticFeedback.lightImpact();
context.read<PinCubit>().unpinStory(story);
},
backgroundColor: Palette.red,
foregroundColor: Palette.white,
icon: preferenceState.showComplexStoryTile
? Icons.close
: null,
label: 'Unpin',
),
],
),
child: Container(
color: Palette.orangeAccent.withOpacity(0.2),
child: StoryTile(
key: ValueKey<String>('${story.id}-PinnedStoryTile'),
story: story,
onTap: () => onStoryTapped(story, isPin: true),
showWebPreview: preferenceState.showComplexStoryTile,
showMetadata: preferenceState.showMetadata,
showUrl: preferenceState.showUrl,
),
),
),
),
if (state.pinnedStories.isNotEmpty)
const Padding(
padding: EdgeInsets.symmetric(horizontal: Dimens.pt12),
child: Divider(
color: Palette.orangeAccent,
),
),
],
);
},
);
return DefaultTabController(
length: 6,
child: Scaffold(
resizeToAvoidBottomInset: false,
appBar: PreferredSize(
preferredSize: const Size(
Dimens.zero,
Dimens.pt40,
),
child: Column(
children: <Widget>[
SizedBox(
height: MediaQuery.of(context).padding.top - Dimens.pt8,
),
Theme(
data: ThemeData(
highlightColor: Palette.transparent,
splashColor: Palette.transparent,
primaryColor: Theme.of(context).primaryColor,
),
child: TabBar(
isScrollable: true,
controller: tabController,
indicatorColor: Palette.orange,
indicator: CircleTabIndicator(
color: Palette.orange,
radius: Dimens.pt2,
),
indicatorPadding: const EdgeInsets.only(
bottom: Dimens.pt8,
),
onTap: (_) {
HapticFeedback.selectionClick();
},
tabs: <Widget>[
for (int i = 0; i < StoriesBloc.types.length; i++)
Tab(
key: ValueKey<StoryType>(
StoriesBloc.types.elementAt(i),
),
child: Text(
StoriesBloc.types.elementAt(i).label,
style: TextStyle(
fontSize: currentIndex == i
? TextDimens.pt14
: TextDimens.pt10,
color: currentIndex == i
? Palette.orange
: Palette.grey,
),
),
),
Tab(
child: DescribedFeatureOverlay(
onBackgroundTap: onFeatureDiscoveryDismissed,
onDismiss: onFeatureDiscoveryDismissed,
onComplete: () async {
ScaffoldMessenger.of(context).clearSnackBars();
unawaited(HapticFeedback.lightImpact());
showOnboarding();
return true;
},
overflowMode: OverflowMode.extendBackground,
targetColor: Theme.of(context).primaryColor,
tapTarget: const Icon(
Icons.person,
size: TextDimens.pt16,
color: Palette.white,
),
featureId: Constants.featureLogIn,
title: const Text('Log in for more'),
description: const Text(
'Log in using your Hacker News account '
'to check out stories and comments you have '
'posted in the past, and get in-app '
'notification when there is new reply to '
'your comments or stories.',
style: TextStyle(fontSize: TextDimens.pt16),
),
child: BlocBuilder<NotificationCubit,
NotificationState>(
buildWhen: (
NotificationState previous,
NotificationState current,
) =>
previous.unreadCommentsIds.length !=
current.unreadCommentsIds.length,
builder: (
BuildContext context,
NotificationState state,
) {
return Badge(
showBadge: state.unreadCommentsIds.isNotEmpty,
borderRadius: BorderRadius.circular(100),
badgeContent: Container(
height: Dimens.pt3,
width: Dimens.pt3,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Palette.white,
),
),
child: Icon(
Icons.person,
size: currentIndex == 5
? TextDimens.pt16
: TextDimens.pt12,
color: currentIndex == 5
? Palette.orange
: Palette.grey,
),
);
},
),
),
),
],
),
),
],
),
),
body: TabBarView(
physics: const NeverScrollableScrollPhysics(),
controller: tabController,
children: <Widget>[
for (final StoryType type in StoriesBloc.types)
StoriesListView(
key: ValueKey<StoryType>(type),
storyType: type,
header: pinnedStories,
onStoryTapped: onStoryTapped,
),
const ProfileScreen(),
],
),
),
);
},
);
return ScreenTypeLayout.builder(
mobile: (BuildContext context) {
context.read<SplitViewCubit>().disableSplitView();
return _MobileHomeScreen(
homeScreen: homeScreen,
);
},
tablet: (BuildContext context) => _TabletHomeScreen(
homeScreen: homeScreen,
),
);
}
Future<bool> onFeatureDiscoveryDismissed() {
featureDiscoveryDismissThrottle.run(() {
HapticFeedback.lightImpact();
ScaffoldMessenger.of(context).clearSnackBars();
showSnackBar(content: 'Tap on icon to continue');
});
return Future<bool>.value(false);
}
void onStoryTapped(Story story, {bool isPin = false}) {
final bool showWebFirst =
context.read<PreferenceCubit>().state.showWebFirst;
final bool useReader = context.read<PreferenceCubit>().state.useReader;
final bool offlineReading =
context.read<StoriesBloc>().state.offlineReading;
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,
// it would be better to just navigate to the web page.
final bool isJobWithLink = story.isJob && story.url.isNotEmpty;
if (isJobWithLink) {
context.read<ReminderCubit>().removeLastReadStoryId();
} else {
final ItemScreenArgs args = ItemScreenArgs(item: story);
context.read<ReminderCubit>().updateLastReadStoryId(story.id);
if (splitViewEnabled) {
context.read<SplitViewCubit>().updateItemScreenArgs(args);
} else {
HackiApp.navigatorKey.currentState
?.pushNamed(
ItemScreen.routeName,
arguments: args,
)
.whenComplete(() {
context.read<ReminderCubit>().removeLastReadStoryId();
});
}
}
if (story.url.isNotEmpty && (isJobWithLink || (showWebFirst && !hasRead))) {
LinkUtil.launch(
story.url,
useReader: useReader,
offlineReading: offlineReading,
);
}
context.read<StoriesBloc>().add(
StoryRead(
story: story,
),
);
if (Platform.isIOS) {
FlutterSiriSuggestions.instance.registerActivity(
FlutterSiriActivity(
story.title,
story.id.toString(),
suggestedInvocationPhrase: '',
contentDescription: story.text,
persistentIdentifier: story.id.toString(),
),
);
}
}
void showOnboarding() {
Navigator.push<dynamic>(
context,
MaterialPageRoute<dynamic>(
builder: (BuildContext context) => const OnboardingView(),
fullscreenDialog: true,
),
);
}
void onShareExtensionTapped(String? event) {
if (event == null) return;
final int? id = event.itemId;
if (id != null) {
locator.get<StoriesRepository>().fetchItemBy(id: id).then((Item? item) {
if (mounted) {
if (item != null) {
goToItemScreen(
args: ItemScreenArgs(item: item),
forceNewScreen: true,
);
}
}
});
}
}
Future<void> onSiriSuggestionTapped(String? id) async {
if (id == null) return;
final int? storyId = int.tryParse(id);
if (storyId == null) return;
await locator
.get<StoriesRepository>()
.fetchStoryBy(storyId)
.then((Story? story) {
if (story == null) {
showSnackBar(content: 'Something went wrong...');
return;
}
final ItemScreenArgs args = ItemScreenArgs(item: story);
goToItemScreen(args: args);
});
}
Future<void> onNotificationTapped(String? payload) async {
if (payload == null) return;
final Map<String, dynamic> payloadJson =
jsonDecode(payload) as Map<String, dynamic>;
final int? storyId = payloadJson['storyId'] as int?;
final int? commentId = payloadJson['commentId'] as int?;
if (storyId != null && commentId != null) {
context.read<NotificationCubit>().markAsRead(commentId);
await locator
.get<StoriesRepository>()
.fetchStoryBy(storyId)
.then((Story? story) {
if (story == null) {
showSnackBar(content: 'Something went wrong...');
return;
}
final ItemScreenArgs args = ItemScreenArgs(item: story);
goToItemScreen(args: args);
});
}
}
}
class _MobileHomeScreen extends StatelessWidget {
const _MobileHomeScreen({
Key? key,
required this.homeScreen,
}) : super(key: key);
final Widget homeScreen;
@override
Widget build(BuildContext context) {
return Stack(
children: <Widget>[
Positioned.fill(child: homeScreen),
if (!context.read<ReminderCubit>().state.hasShown)
const Positioned(
left: Dimens.pt24,
right: Dimens.pt24,
bottom: Dimens.pt36,
height: Dimens.pt40,
child: CountdownReminder(),
),
],
);
}
}
class _TabletHomeScreen extends StatelessWidget {
const _TabletHomeScreen({
Key? key,
required this.homeScreen,
}) : super(key: key);
final Widget homeScreen;
@override
Widget build(BuildContext context) {
return ResponsiveBuilder(
builder: (BuildContext context, SizingInformation sizeInfo) {
context.read<SplitViewCubit>().enableSplitView();
double homeScreenWidth = 428;
if (sizeInfo.screenSize.width < homeScreenWidth * 2) {
homeScreenWidth = 345;
}
return BlocBuilder<SplitViewCubit, SplitViewState>(
buildWhen: (SplitViewState previous, SplitViewState current) =>
previous.expanded != current.expanded,
builder: (BuildContext context, SplitViewState state) {
return Stack(
children: <Widget>[
AnimatedPositioned(
left: Dimens.zero,
top: Dimens.zero,
bottom: Dimens.zero,
width: state.expanded ? Dimens.zero : homeScreenWidth,
duration: const Duration(milliseconds: 300),
curve: Curves.elasticOut,
child: homeScreen,
),
Positioned(
left: Dimens.pt24,
bottom: Dimens.pt36,
height: Dimens.pt40,
width: homeScreenWidth - Dimens.pt24,
child: const CountdownReminder(),
),
AnimatedPositioned(
right: Dimens.zero,
top: Dimens.zero,
bottom: Dimens.zero,
left: state.expanded ? Dimens.zero : homeScreenWidth,
duration: const Duration(milliseconds: 300),
curve: Curves.elasticOut,
child: const _TabletStoryView(),
),
],
);
},
);
},
);
}
}
class _TabletStoryView extends StatelessWidget {
const _TabletStoryView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlocBuilder<SplitViewCubit, SplitViewState>(
buildWhen: (SplitViewState previous, SplitViewState current) =>
previous.itemScreenArgs != current.itemScreenArgs,
builder: (BuildContext context, SplitViewState state) {
if (state.itemScreenArgs != null) {
return ItemScreen.build(context, state.itemScreenArgs!);
}
return Material(
child: Container(
color: Theme.of(context).canvasColor,
child: const Center(
child: Text('Tap on story tile to view comments.'),
),
),
);
},
);
}
}

View File

@ -13,15 +13,12 @@ import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/main.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/item/models/models.dart';
import 'package:hacki/screens/item/widgets/widgets.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'package:responsive_builder/responsive_builder.dart';
import 'package:share_plus/share_plus.dart';
class ItemScreenArgs extends Equatable {
const ItemScreenArgs({
@ -152,7 +149,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
initialRefreshStatus: RefreshStatus.refreshing,
);
final FocusNode focusNode = FocusNode();
final String happyFace = Constants.happyFaces.pickRandomly()!;
final Throttle storyLinkTapThrottle = Throttle(
delay: _storyLinkTapThrottleDelay,
);
@ -236,8 +232,7 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
context.read<EditCubit>().state.replyingTo == null
? 'updated'
: 'submitted';
final String msg =
'Comment $verb! ${Constants.happyFaces.pickRandomly()}';
final String msg = 'Comment $verb! ${Constants.happyFace}';
focusNode.unfocus();
HapticFeedback.lightImpact();
showSnackBar(content: msg);
@ -246,7 +241,7 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
} else if (postState.status == PostStatus.failure) {
showSnackBar(
content: 'Something went wrong...'
'${Constants.sadFaces.pickRandomly()}',
'${Constants.sadFace}',
label: 'Okay',
action: ScaffoldMessenger.of(context).hideCurrentSnackBar,
);
@ -530,154 +525,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
);
}
Future<void> onStoryLinkTapped(String link) async {
final int? id = link.itemId;
if (id != null) {
storyLinkTapThrottle.run(() {
locator.get<StoriesRepository>().fetchItemBy(id: id).then((Item? item) {
if (mounted) {
if (item != null) {
HackiApp.navigatorKey.currentState!.pushNamed(
ItemScreen.routeName,
arguments: ItemScreenArgs(item: item),
);
}
}
});
});
} else {
LinkUtil.launch(link);
}
}
void onMoreTapped(Item item, Rect? rect) {
HapticFeedback.lightImpact();
if (item.dead || item.deleted) {
return;
}
final bool isBlocked =
context.read<BlocklistCubit>().state.blocklist.contains(item.by);
showModalBottomSheet<MenuAction>(
context: context,
builder: (BuildContext context) {
return MorePopupMenu(
item: item,
isBlocked: isBlocked,
showSnackBar: showSnackBar,
onStoryLinkTapped: onStoryLinkTapped,
onLoginTapped: onLoginTapped,
);
},
).then((MenuAction? action) {
if (action != null) {
switch (action) {
case MenuAction.upvote:
break;
case MenuAction.downvote:
break;
case MenuAction.share:
onShareTapped(item, rect);
break;
case MenuAction.flag:
onFlagTapped(item);
break;
case MenuAction.block:
onBlockTapped(item, isBlocked: isBlocked);
break;
case MenuAction.cancel:
break;
}
}
});
}
void onShareTapped(Item item, Rect? rect) {
Share.share(
'https://news.ycombinator.com/item?id=${item.id}',
sharePositionOrigin: rect,
);
}
void onFlagTapped(Item item) {
showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Flag this comment?'),
content: Text(
'Flag this comment posted by ${item.by}?',
style: const TextStyle(
color: Palette.grey,
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text(
'Cancel',
),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text(
'Yes',
),
),
],
);
},
).then((bool? yesTapped) {
if (yesTapped ?? false) {
context.read<AuthBloc>().add(AuthFlag(item: item));
showSnackBar(content: 'Comment flagged!');
}
});
}
void onBlockTapped(Item item, {required bool isBlocked}) {
showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text('${isBlocked ? 'Unblock' : 'Block'} this user?'),
content: Text(
'Do you want to ${isBlocked ? 'unblock' : 'block'} ${item.by}'
' and ${isBlocked ? 'display' : 'hide'} '
'comments posted by this user?',
style: const TextStyle(
color: Palette.grey,
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text(
'Cancel',
),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text(
'Yes',
),
),
],
);
},
).then((bool? yesTapped) {
if (yesTapped ?? false) {
if (isBlocked) {
context.read<BlocklistCubit>().removeFromBlocklist(item.by);
} else {
context.read<BlocklistCubit>().addToBlocklist(item.by);
}
showSnackBar(content: 'User ${isBlocked ? 'unblocked' : 'blocked'}!');
}
});
}
void onSendTapped() {
final AuthBloc authBloc = context.read<AuthBloc>();
final PostCubit postCubit = context.read<PostCubit>();
@ -700,20 +547,4 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
onLoginTapped();
}
}
void onLoginTapped() {
final TextEditingController usernameController = TextEditingController();
final TextEditingController passwordController = TextEditingController();
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return LoginDialog(
usernameController: usernameController,
passwordController: passwordController,
showSnackBar: showSnackBar,
);
},
);
}
}

View File

@ -7,10 +7,10 @@ import 'package:hacki/styles/styles.dart';
class CustomAppBar extends AppBar {
CustomAppBar({
Key? key,
super.key,
required ScrollController scrollController,
required Item item,
required Color backgroundColor,
required Color super.backgroundColor,
required Future<bool> Function() onBackgroundTap,
required Future<bool> Function() onDismiss,
required VoidCallback onFontSizeTap,
@ -19,8 +19,6 @@ class CustomAppBar extends AppBar {
VoidCallback? onZoomTap,
bool? expanded,
}) : super(
key: key,
backgroundColor: backgroundColor,
elevation: Dimens.zero,
actions: <Widget>[
if (splitViewEnabled) ...<Widget>[

View File

@ -2,22 +2,21 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
class LoginDialog extends StatelessWidget {
const LoginDialog({
Key? key,
super.key,
required this.usernameController,
required this.passwordController,
required this.showSnackBar,
}) : super(key: key);
});
final TextEditingController usernameController;
final TextEditingController passwordController;
final Function({
final void Function({
required String content,
VoidCallback? action,
String? label,
@ -28,9 +27,10 @@ class LoginDialog extends StatelessWidget {
return BlocConsumer<AuthBloc, AuthState>(
listener: (BuildContext context, AuthState state) {
if (state.isLoggedIn) {
final String happyFace = Constants.happyFaces.pickRandomly()!;
Navigator.pop(context);
showSnackBar(content: 'Logged in successfully! $happyFace');
showSnackBar(
content: 'Logged in successfully! ${Constants.happyFace}',
);
}
},
builder: (BuildContext context, AuthState state) {

View File

@ -17,7 +17,7 @@ import 'package:pull_to_refresh/pull_to_refresh.dart';
class MainView extends StatelessWidget {
const MainView({
Key? key,
super.key,
required this.scrollController,
required this.refreshController,
required this.commentEditingController,
@ -29,7 +29,7 @@ class MainView extends StatelessWidget {
required this.onStoryLinkTapped,
required this.onLoginTapped,
required this.onRightMoreTapped,
}) : super(key: key);
});
final ScrollController scrollController;
final RefreshController refreshController;
@ -38,11 +38,14 @@ class MainView extends StatelessWidget {
final FocusNode focusNode;
final double topPadding;
final bool splitViewEnabled;
final Function(Item item, Rect? rect) onMoreTapped;
final void Function(Item item, Rect? rect) onMoreTapped;
final ValueChanged<String> onStoryLinkTapped;
final VoidCallback onLoginTapped;
final ValueChanged<Comment> onRightMoreTapped;
static const int _loadingIndicatorOpacityAnimationDuration = 300;
static const double _trailingBoxHeight = 240;
@override
Widget build(BuildContext context) {
return Stack(
@ -130,9 +133,9 @@ class MainView extends StatelessWidget {
state.comments.isNotEmpty) ||
state.onlyShowTargetComment) {
return SizedBox(
height: 240,
height: _trailingBoxHeight,
child: Center(
child: Text(Constants.happyFaces.pickRandomly()!),
child: Text(Constants.happyFace),
),
);
} else {
@ -194,8 +197,13 @@ class MainView extends StatelessWidget {
buildWhen: (CommentsState prev, CommentsState current) =>
prev.status != current.status,
builder: (BuildContext context, CommentsState state) {
return Visibility(
visible: state.status == CommentsStatus.loading,
return AnimatedOpacity(
opacity: state.status == CommentsStatus.loading
? NumSwitch.on
: NumSwitch.off,
duration: const Duration(
milliseconds: _loadingIndicatorOpacityAnimationDuration,
),
child: const LinearProgressIndicator(),
);
},
@ -208,7 +216,6 @@ class MainView extends StatelessWidget {
class _ParentItemSection extends StatelessWidget {
const _ParentItemSection({
Key? key,
required this.scrollController,
required this.refreshController,
required this.commentEditingController,
@ -221,7 +228,7 @@ class _ParentItemSection extends StatelessWidget {
required this.onStoryLinkTapped,
required this.onLoginTapped,
required this.onRightMoreTapped,
}) : super(key: key);
});
final ScrollController scrollController;
final RefreshController refreshController;
@ -231,7 +238,7 @@ class _ParentItemSection extends StatelessWidget {
final FocusNode focusNode;
final double topPadding;
final bool splitViewEnabled;
final Function(Item item, Rect? rect) onMoreTapped;
final void Function(Item item, Rect? rect) onMoreTapped;
final ValueChanged<String> onStoryLinkTapped;
final VoidCallback onLoginTapped;
final ValueChanged<Comment> onRightMoreTapped;
@ -317,8 +324,10 @@ class _ParentItemSection extends StatelessWidget {
InkWell(
onTap: () => LinkUtil.launch(
state.item.url,
useReader:
context.read<PreferenceCubit>().state.useReader,
useReader: context
.read<PreferenceCubit>()
.state
.readerEnabled,
offlineReading: context
.read<StoriesBloc>()
.state
@ -342,7 +351,7 @@ class _ParentItemSection extends StatelessWidget {
prefState.fontSize.fontSize,
color: Theme.of(context)
.textTheme
.bodyText1
.bodyLarge
?.color,
),
children: <TextSpan>[

View File

@ -12,17 +12,17 @@ import 'package:hacki/utils/utils.dart';
class MorePopupMenu extends StatelessWidget {
const MorePopupMenu({
Key? key,
super.key,
required this.item,
required this.isBlocked,
required this.showSnackBar,
required this.onStoryLinkTapped,
required this.onLoginTapped,
}) : super(key: key);
});
final Item item;
final bool isBlocked;
final Function({
final void Function({
required String content,
VoidCallback? action,
String? label,

View File

@ -168,7 +168,7 @@ class PollView extends StatelessWidget {
? SnackBarAction(
label: label,
onPressed: action,
textColor: Theme.of(context).textTheme.bodyText1?.color,
textColor: Theme.of(context).textTheme.bodyLarge?.color,
)
: null,
behavior: SnackBarBehavior.floating,

View File

@ -111,7 +111,8 @@ class _ReplyBoxState extends State<ReplyBox> {
...<Widget>[
if (replyingTo != null)
AnimatedOpacity(
opacity: expanded ? 1 : 0,
opacity:
expanded ? NumSwitch.on : NumSwitch.off,
duration: const Duration(milliseconds: 300),
child: IconButton(
key: const Key('quote'),

View File

@ -9,19 +9,19 @@ import 'package:responsive_builder/responsive_builder.dart';
class TimeMachineDialog extends StatelessWidget {
const TimeMachineDialog({
Key? key,
super.key,
required this.comment,
required this.size,
required this.deviceType,
required this.widthFactor,
required this.onStoryLinkTapped,
}) : super(key: key);
});
final Comment comment;
final Size size;
final DeviceScreenType deviceType;
final double widthFactor;
final Function(String) onStoryLinkTapped;
final void Function(String) onStoryLinkTapped;
@override
Widget build(BuildContext context) {

View File

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

View File

@ -0,0 +1,7 @@
enum PageType {
fav,
history,
settings,
search,
notification,
}

File diff suppressed because it is too large Load Diff

View File

@ -22,7 +22,7 @@ class InboxView extends StatelessWidget {
final RefreshController refreshController;
final List<Comment> comments;
final List<int> unreadCommentsIds;
final Function(Comment) onCommentTapped;
final void Function(Comment) onCommentTapped;
final VoidCallback onMarkAllAsReadTapped;
final VoidCallback onLoadMore;
final VoidCallback onRefresh;

View File

@ -58,42 +58,67 @@ class OfflineListTile extends StatelessWidget {
isThreeLine: true,
onTap: () {
if (state.downloadStatus == StoriesDownloadStatus.downloading) {
return;
showDialog<bool>(
context: context,
builder: (BuildContext context) => AlertDialog(
title: const Text('Abort downloading?'),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('No'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Yes'),
),
],
),
).then((bool? abortDownloading) {
if (abortDownloading ?? false) {
Wakelock.enable();
context.read<StoriesBloc>().add(StoriesCancelDownload());
}
});
} else {
Connectivity().checkConnectivity().then((ConnectivityResult res) {
if (res != ConnectivityResult.none) {
showDialog<bool>(
context: context,
builder: (BuildContext context) => AlertDialog(
title: const Text('Download web pages as well?'),
content: const Text('It will take longer time.'),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('No'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Yes'),
),
],
),
).then((bool? includeWebPage) {
if (includeWebPage != null) {
Wakelock.enable();
context.read<StoriesBloc>().add(
StoriesDownload(
includingWebPage: includeWebPage,
),
);
}
});
}
});
}
Connectivity().checkConnectivity().then((ConnectivityResult res) {
if (res != ConnectivityResult.none) {
showDialog<bool>(
context: context,
builder: (BuildContext context) => AlertDialog(
title: const Text('Download web pages as well?'),
content: const Text('It will take longer time.'),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('No'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Yes'),
),
],
),
).then((bool? includeWebPage) {
if (includeWebPage != null) {
Wakelock.enable();
context.read<StoriesBloc>().add(
StoriesDownload(
includingWebPage: includeWebPage,
),
);
}
});
}
});
},
);
},

View File

@ -0,0 +1,561 @@
import 'dart:io';
import 'package:adaptive_theme/adaptive_theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_email_sender/flutter_email_sender.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/profile/models/page_type.dart';
import 'package:hacki/screens/profile/widgets/offline_list_tile.dart';
import 'package:hacki/screens/profile/widgets/tab_bar_settings.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:logger/logger.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'package:share_plus/share_plus.dart';
class Settings extends StatefulWidget {
const Settings({
super.key,
required this.authState,
required this.magicWord,
required this.pageType,
required this.onLoginTapped,
});
final AuthState authState;
final String magicWord;
final PageType pageType;
final VoidCallback onLoginTapped;
@override
State<Settings> createState() => _SettingsState();
}
class _SettingsState extends State<Settings> {
@override
Widget build(BuildContext context) {
return BlocBuilder<PreferenceCubit, PreferenceState>(
builder: (BuildContext context, PreferenceState preferenceState) {
return Positioned.fill(
top: Dimens.pt50,
child: Visibility(
visible: widget.pageType == PageType.settings,
child: SingleChildScrollView(
child: Column(
children: <Widget>[
ListTile(
title: Text(
widget.authState.isLoggedIn ? 'Log Out' : 'Log In',
),
subtitle: Text(
widget.authState.isLoggedIn
? widget.authState.username
: widget.magicWord,
),
onTap: () {
if (widget.authState.isLoggedIn) {
onLogoutTapped();
} else {
widget.onLoginTapped();
}
},
),
const OfflineListTile(),
const SizedBox(
height: Dimens.pt8,
),
Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Flexible(
child: Row(
children: const <Widget>[
SizedBox(
width: Dimens.pt16,
),
Text('Default fetch mode'),
Spacer(),
],
),
),
Flexible(
child: Row(
children: const <Widget>[
Text('Default comments order'),
Spacer(),
],
),
),
],
),
Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Flexible(
child: Row(
children: <Widget>[
const SizedBox(
width: Dimens.pt16,
),
DropdownButton<FetchMode>(
value: preferenceState.fetchMode,
underline: const SizedBox.shrink(),
items: FetchMode.values
.map(
(FetchMode val) =>
DropdownMenuItem<FetchMode>(
value: val,
child: Text(
val.description,
style: const TextStyle(
fontSize: TextDimens.pt16,
),
),
),
)
.toList(),
onChanged: (FetchMode? fetchMode) {
if (fetchMode != null) {
HapticFeedback.selectionClick();
context.read<PreferenceCubit>().update(
FetchModePreference(),
to: fetchMode.index,
);
}
},
),
const Spacer(),
],
),
),
Flexible(
child: Row(
children: <Widget>[
DropdownButton<CommentsOrder>(
value: preferenceState.order,
underline: const SizedBox.shrink(),
items: CommentsOrder.values
.map(
(CommentsOrder val) =>
DropdownMenuItem<CommentsOrder>(
value: val,
child: Text(
val.description,
style: const TextStyle(
fontSize: TextDimens.pt16,
),
),
),
)
.toList(),
onChanged: (CommentsOrder? order) {
if (order != null) {
HapticFeedback.selectionClick();
context.read<PreferenceCubit>().update(
CommentsOrderPreference(),
to: order.index,
);
}
},
),
const Spacer(),
],
),
),
],
),
const TabBarSettings(),
const Divider(),
StoryTile(
showWebPreview: preferenceState.complexStoryTileEnabled,
showMetadata: preferenceState.metadataEnabled,
showUrl: preferenceState.urlEnabled,
story: Story.placeholder(),
onTap: () => LinkUtil.launch(Constants.guidelineLink),
),
const Divider(),
for (final Preference<dynamic> preference in preferenceState
.preferences
.whereType<BooleanPreference>()
.where(
(Preference<dynamic> e) => e.isDisplayable,
))
SwitchListTile(
title: Text(preference.title),
subtitle: preference.subtitle.isNotEmpty
? Text(preference.subtitle)
: null,
value: preferenceState.isOn(
preference as BooleanPreference,
),
onChanged: (bool val) {
HapticFeedback.lightImpact();
context
.read<PreferenceCubit>()
.update(preference, to: val);
if (preference is MarkReadStoriesModePreference &&
val == false) {
context
.read<StoriesBloc>()
.add(ClearAllReadStories());
}
},
activeColor: Palette.orange,
),
ListTile(
title: const Text(
'Theme',
),
onTap: showThemeSettingDialog,
),
ListTile(
title: const Text(
'Clear Data',
),
onTap: showClearDataDialog,
),
ListTile(
title: const Text('About'),
subtitle: const Text('nothing interesting here.'),
onTap: showAboutHackiDialog,
),
const SizedBox(
height: Dimens.pt48,
),
],
),
),
),
);
},
);
}
void onLogoutTapped() {
final AuthBloc authBloc = context.read<AuthBloc>();
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
content: Text(
'Log out as ${authBloc.state.username}?',
style: const TextStyle(
fontSize: TextDimens.pt16,
),
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
'Cancel',
),
),
TextButton(
onPressed: () {
Navigator.pop(context);
context.read<AuthBloc>().add(AuthLogout());
context.read<HistoryCubit>().reset();
},
child: const Text(
'Log out',
),
),
],
);
},
);
}
void showThemeSettingDialog() {
showDialog<void>(
context: context,
builder: (_) {
final AdaptiveThemeMode themeMode = AdaptiveTheme.of(context).mode;
return AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
RadioListTile<AdaptiveThemeMode>(
value: AdaptiveThemeMode.light,
groupValue: themeMode,
onChanged: (AdaptiveThemeMode? val) =>
AdaptiveTheme.of(context).setLight(),
title: const Text('Light'),
),
RadioListTile<AdaptiveThemeMode>(
value: AdaptiveThemeMode.dark,
groupValue: themeMode,
onChanged: (AdaptiveThemeMode? val) =>
AdaptiveTheme.of(context).setDark(),
title: const Text('Dark'),
),
RadioListTile<AdaptiveThemeMode>(
value: AdaptiveThemeMode.system,
groupValue: themeMode,
onChanged: (AdaptiveThemeMode? val) =>
AdaptiveTheme.of(context).setSystem(),
title: const Text('System'),
),
],
),
);
},
);
}
void showClearDataDialog() {
showDialog<void>(
context: context,
builder: (_) {
return AlertDialog(
title: const Text('Clear Data?'),
content: const Text(
'Clear all cached images, stories and comments.',
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text(
'Cancel',
style: TextStyle(
color: Palette.orange,
),
),
),
TextButton(
onPressed: () {
Navigator.pop(context);
locator
.get<SembastRepository>()
.deleteAllCachedComments()
.whenComplete(
locator.get<OfflineRepository>().deleteAll,
)
.whenComplete(
locator.get<PreferenceRepository>().clearAllReadStories,
)
.whenComplete(
DefaultCacheManager().emptyCache,
)
.whenComplete(() {
showSnackBar(content: 'Data cleared!');
});
},
child: const Text(
'Yes',
),
),
],
);
},
);
}
Future<void> showAboutHackiDialog() async {
final PackageInfo packageInfo = await PackageInfo.fromPlatform();
final String version = packageInfo.version;
if (mounted) {
showAboutDialog(
context: context,
applicationName: 'Hacki',
applicationVersion: 'v$version',
applicationIcon: ClipRRect(
borderRadius: const BorderRadius.all(
Radius.circular(
Dimens.pt12,
),
),
child: Image.asset(
Constants.hackiIconPath,
height: Dimens.pt50,
width: Dimens.pt50,
),
),
children: <Widget>[
ElevatedButton(
onPressed: () => LinkUtil.launch(
Constants.portfolioLink,
),
child: Row(
children: const <Widget>[
Icon(
FontAwesomeIcons.addressCard,
),
SizedBox(
width: Dimens.pt12,
),
Text('Developer'),
],
),
),
ElevatedButton(
onPressed: onReportIssueTapped,
child: Row(
children: const <Widget>[
Icon(
Icons.bug_report_outlined,
),
SizedBox(
width: Dimens.pt12,
),
Text('Report issue'),
],
),
),
ElevatedButton(
onPressed: () => LinkUtil.launch(
Constants.githubLink,
),
child: Row(
children: const <Widget>[
Icon(
FontAwesomeIcons.github,
),
SizedBox(
width: Dimens.pt12,
),
Text('Source code'),
],
),
),
ElevatedButton(
onPressed: () => LinkUtil.launch(
Platform.isIOS
? Constants.appStoreLink
: Constants.googlePlayLink,
),
child: Row(
children: const <Widget>[
Icon(
Icons.thumb_up,
),
SizedBox(
width: Dimens.pt12,
),
Text('Like this app?'),
],
),
),
ElevatedButton(
onPressed: () => LinkUtil.launch(
Constants.sponsorLink,
),
child: Row(
children: const <Widget>[
Icon(
FeatherIcons.coffee,
),
SizedBox(
width: Dimens.pt12,
),
Text('Buy me a coffee'),
],
),
),
],
);
}
}
Future<void> onReportIssueTapped() async {
await showDialog<void>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
actions: <Widget>[
ElevatedButton(
onPressed: onSendEmailTapped,
child: Row(
children: const <Widget>[
Icon(
Icons.email,
),
SizedBox(
width: Dimens.pt12,
),
Text('Email'),
],
),
),
ElevatedButton(
onPressed: () => onGithubTapped(context.rect),
child: Row(
children: const <Widget>[
Icon(
Icons.bug_report_outlined,
),
SizedBox(
width: Dimens.pt12,
),
Text('GitHub'),
],
),
),
],
);
},
);
}
/// Send an email with log attached.
Future<void> onSendEmailTapped() async {
final Directory tempDir = await getTemporaryDirectory();
final String previousLogPath =
'${tempDir.path}/${Constants.previousLogFileName}';
await LogUtil.exportLog();
final Email email = Email(
body:
'''Please describe how to reproduce the bug or what you have down before the bug occurred:''',
subject: 'Found a bug in Hacki',
recipients: <String>[Constants.supportEmail],
attachmentPaths: <String>[previousLogPath],
);
await FlutterEmailSender.send(email);
}
/// Open an issue on GitHub.
Future<void> onGithubTapped(Rect? rect) async {
try {
final File originalFile = await LogUtil.exportLog();
final XFile file = XFile(originalFile.path);
final ShareResult result = await Share.shareXFiles(
<XFile>[file],
subject: 'hacki_log',
sharePositionOrigin: rect,
);
if (result.status == ShareResultStatus.success) {
LinkUtil.launchInExternalBrowser(Constants.githubIssueLink);
}
} catch (error, stackTrace) {
locator.get<Logger>().e(
'Error caught in onGithubTapped',
error,
stackTrace,
);
}
}
}

View File

@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/styles/styles.dart';
class TabBarSettings extends StatefulWidget {
const TabBarSettings({super.key});
@override
State<TabBarSettings> createState() => _TabBarSettingsState();
}
class _TabBarSettingsState extends State<TabBarSettings> {
static const double height = 60;
static const double width = 300;
@override
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Row(
children: const <Widget>[
SizedBox(
width: Dimens.pt16,
),
Text('Default tab bar'),
Spacer(),
],
),
BlocBuilder<TabCubit, TabState>(
builder: (BuildContext context, TabState state) {
return Center(
child: SizedBox(
height: height,
width: width,
child: ReorderableListView(
scrollDirection: Axis.horizontal,
physics: const NeverScrollableScrollPhysics(),
onReorder: context.read<TabCubit>().update,
onReorderStart: (_) => HapticFeedback.lightImpact(),
children: <Widget>[
for (final StoryType tab in state.tabs)
InkWell(
key: ValueKey<StoryType>(tab),
child: SizedBox(
width: 60,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(tab.label),
const Icon(
Icons.drag_handle_outlined,
color: Palette.grey,
size: TextDimens.pt14,
),
],
),
),
),
],
),
),
);
},
),
],
);
}
}

View File

@ -1,3 +1,5 @@
export 'centered_message_view.dart';
export 'inbox_view.dart';
export 'offline_list_tile.dart';
export 'settings.dart';
export 'tab_bar_settings.dart';

View File

@ -1,4 +1,4 @@
export 'home_screen.dart';
export 'home/home_screen.dart';
export 'item/item_screen.dart';
export 'profile/profile_screen.dart';
export 'search/search_screen.dart';

View File

@ -164,23 +164,22 @@ class _SearchScreenState extends State<SearchScreen> {
FadeIn(
child: StoryTile(
showWebPreview:
prefState.showComplexStoryTile,
showMetadata: prefState.showMetadata,
showUrl: prefState.showUrl,
prefState.complexStoryTileEnabled,
showMetadata: prefState.metadataEnabled,
showUrl: prefState.urlEnabled,
story: e,
onTap: () => goToItemScreen(
args: ItemScreenArgs(item: e),
),
),
),
if (!prefState.showComplexStoryTile)
if (!prefState.complexStoryTileEnabled)
const Divider(
height: Dimens.zero,
),
],
)
.expand((List<Widget> e) => e)
.toList(),
.expand((List<Widget> e) => e),
const SizedBox(
height: Dimens.pt40,
),

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:hacki/screens/widgets/widgets.dart';
typedef DateRangeCallback = Function(DateTime, DateTime);
typedef DateRangeCallback = void Function(DateTime, DateTime);
enum CustomDateTimeRange {
pastDay(Duration(days: 1), label: 'past day'),
@ -17,10 +17,10 @@ enum CustomDateTimeRange {
class CustomRangeFilterChip extends StatelessWidget {
const CustomRangeFilterChip({
Key? key,
super.key,
required this.range,
required this.onTap,
}) : super(key: key);
});
final CustomDateTimeRange range;
final DateRangeCallback onTap;

View File

@ -5,14 +5,14 @@ import 'package:intl/intl.dart';
class DateTimeRangeFilterChip extends StatelessWidget {
const DateTimeRangeFilterChip({
Key? key,
super.key,
required this.filter,
required this.onDateTimeRangeUpdated,
required this.onDateTimeRangeRemoved,
}) : super(key: key);
});
final DateTimeRangeFilter? filter;
final Function(DateTime, DateTime) onDateTimeRangeUpdated;
final void Function(DateTime, DateTime) onDateTimeRangeUpdated;
final VoidCallback onDateTimeRangeRemoved;
static final DateFormat _dateTimeFormatter = DateFormat.yMMMd();

View File

@ -4,9 +4,9 @@ import 'package:hacki/screens/widgets/widgets.dart';
class PostedByFilterChip extends StatelessWidget {
const PostedByFilterChip({
Key? key,
super.key,
required this.filter,
}) : super(key: key);
});
final PostedByFilter? filter;

View File

@ -17,6 +17,25 @@ class WebViewScreen extends StatefulWidget {
}
class _WebViewScreenState extends State<WebViewScreen> {
final WebViewController controller = WebViewController();
@override
void initState() {
super.initState();
getUrlAndLoadWebView();
}
Future<void> getUrlAndLoadWebView() async {
final String? html = await locator.get<OfflineRepository>().getHtml(
url: widget.url,
);
if (html != null) {
await controller.loadHtmlString(html);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
@ -32,16 +51,8 @@ class _WebViewScreenState extends State<WebViewScreen> {
),
centerTitle: true,
),
body: WebView(
onWebViewCreated: (WebViewController controller) async {
final String? html = await locator.get<OfflineRepository>().getHtml(
url: widget.url,
);
if (html != null) {
await controller.loadHtmlString(html);
}
},
body: WebViewWidget(
controller: controller,
),
);
}

View File

@ -17,7 +17,7 @@ class BlocBuilder3<
BlocC extends StateStreamable<BlocCState>,
BlocCState> extends StatelessWidget {
const BlocBuilder3({
Key? key,
super.key,
required this.builder,
this.blocA,
this.blocB,
@ -25,7 +25,7 @@ class BlocBuilder3<
this.buildWhenA,
this.buildWhenB,
this.buildWhenC,
}) : super(key: key);
});
final BlocWidgetBuilder3<BlocAState, BlocBState, BlocCState> builder;

View File

@ -33,11 +33,11 @@ class CommentTile extends StatelessWidget {
final Comment comment;
final int level;
final bool actionable;
final Function(Comment)? onReplyTapped;
final Function(Comment, Rect?)? onMoreTapped;
final Function(Comment)? onEditTapped;
final Function(Comment)? onRightMoreTapped;
final Function(String) onStoryLinkTapped;
final void Function(Comment)? onReplyTapped;
final void Function(Comment, Rect?)? onMoreTapped;
final void Function(Comment)? onEditTapped;
final void Function(Comment)? onRightMoreTapped;
final void Function(String) onStoryLinkTapped;
final FetchMode fetchMode;
@override
@ -135,8 +135,9 @@ class CommentTile extends StatelessWidget {
Text(
comment.by,
style: TextStyle(
color:
prefState.showEyeCandy ? orange : color,
color: prefState.eyeCandyEnabled
? orange
: color,
),
),
if (comment.by == opUsername)
@ -328,7 +329,7 @@ class CommentTile extends StatelessWidget {
final double commentBackgroundColorOpacity =
Theme.of(context).brightness == Brightness.dark ? 0.03 : 0.15;
final Color commentColor = prefState.showEyeCandy
final Color commentColor = prefState.eyeCandyEnabled
? color.withOpacity(commentBackgroundColorOpacity)
: Palette.transparent;
final bool isMyComment = myUsername == comment.by;
@ -406,7 +407,7 @@ class CommentTile extends StatelessWidget {
}
void onTextTapped(BuildContext context) {
if (context.read<PreferenceCubit>().state.tapAnywhereToCollapse) {
if (context.read<PreferenceCubit>().state.tapAnywhereToCollapseEnabled) {
HapticFeedback.selectionClick();
context.read<CollapseCubit>().collapse();
}

View File

@ -0,0 +1,181 @@
import 'dart:async';
import 'package:badges/badges.dart';
import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart' hide Badge;
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/circle_tab_indicator.dart';
import 'package:hacki/screens/widgets/onboarding_view.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
class CustomTabBar extends StatefulWidget {
const CustomTabBar({
super.key,
required this.tabController,
});
final TabController tabController;
@override
State<CustomTabBar> createState() => _CustomTabBarState();
}
class _CustomTabBarState extends State<CustomTabBar> {
final Throttle featureDiscoveryDismissThrottle = Throttle(
delay: _throttleDelay,
);
static const Duration _throttleDelay = Duration(seconds: 1);
late List<StoryType> tabs = context.read<TabCubit>().state.tabs;
int currentIndex = 0;
@override
void initState() {
super.initState();
widget.tabController.addListener(() {
setState(() {
currentIndex = widget.tabController.index;
});
});
}
@override
Widget build(BuildContext context) {
return BlocBuilder<TabCubit, TabState>(
builder: (BuildContext context, TabState state) {
return TabBar(
isScrollable: true,
controller: widget.tabController,
indicatorColor: Palette.orange,
indicator: CircleTabIndicator(
color: Palette.orange,
radius: Dimens.pt2,
),
indicatorPadding: const EdgeInsets.only(
bottom: Dimens.pt8,
),
onTap: (_) {
HapticFeedback.selectionClick();
},
tabs: <Widget>[
for (int i = 0; i < state.tabs.length; i++)
Tab(
key: ValueKey<StoryType>(
state.tabs.elementAt(i),
),
child: AnimatedDefaultTextStyle(
style: TextStyle(
fontSize:
currentIndex == i ? TextDimens.pt14 : TextDimens.pt10,
color: currentIndex == i ? Palette.orange : Palette.grey,
),
duration: const Duration(milliseconds: 200),
child: Text(
state.tabs.elementAt(i).label,
key: ValueKey<String>(
'${state.tabs.elementAt(i).label}-${currentIndex == i}',
),
),
),
),
Tab(
child: DescribedFeatureOverlay(
onBackgroundTap: onFeatureDiscoveryDismissed,
onDismiss: onFeatureDiscoveryDismissed,
onComplete: () async {
ScaffoldMessenger.of(context).clearSnackBars();
unawaited(HapticFeedback.lightImpact());
showOnboarding();
return true;
},
overflowMode: OverflowMode.extendBackground,
targetColor: Theme.of(context).primaryColor,
tapTarget: const Icon(
Icons.person,
size: TextDimens.pt16,
color: Palette.white,
),
featureId: Constants.featureLogIn,
title: const Text('Log in for more'),
description: const Text(
'Log in using your Hacker News account '
'to check out stories and comments you have '
'posted in the past, and get in-app '
'notification when there is new reply to '
'your comments or stories.',
style: TextStyle(fontSize: TextDimens.pt16),
),
child: BlocBuilder<NotificationCubit, NotificationState>(
buildWhen: (
NotificationState previous,
NotificationState current,
) =>
previous.unreadCommentsIds.length !=
current.unreadCommentsIds.length,
builder: (
BuildContext context,
NotificationState state,
) {
return Badge(
showBadge: state.unreadCommentsIds.isNotEmpty,
badgeContent: Container(
height: Dimens.pt3,
width: Dimens.pt3,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Palette.white,
),
),
child: Icon(
Icons.person,
size: currentIndex == 5
? TextDimens.pt16
: TextDimens.pt12,
color:
currentIndex == 5 ? Palette.orange : Palette.grey,
),
);
},
),
),
),
],
);
},
);
}
void showOnboarding() {
Navigator.push<dynamic>(
context,
MaterialPageRoute<dynamic>(
builder: (BuildContext context) => const OnboardingView(),
fullscreenDialog: true,
),
);
}
Future<bool> onFeatureDiscoveryDismissed() {
featureDiscoveryDismissThrottle.run(() {
HapticFeedback.lightImpact();
ScaffoldMessenger.of(context).clearSnackBars();
showSnackBar(content: 'Tap on icon to continue');
});
return Future<bool>.value(false);
}
@override
void dispose() {
featureDiscoveryDismissThrottle.dispose();
super.dispose();
}
}

View File

@ -5,6 +5,8 @@ import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/context_extension.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
@ -31,6 +33,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
this.onLoadMore,
this.onPinned,
this.header,
this.onMoreTapped,
}) : assert(
!pinnable || (pinnable && onPinned != null),
'onPinned cannot be null when pinnable is true',
@ -57,7 +60,10 @@ class ItemsListView<T extends Item> extends StatelessWidget {
final VoidCallback? onRefresh;
final VoidCallback? onLoadMore;
final ValueChanged<Story>? onPinned;
final Function(T) onTap;
final void Function(T) onTap;
/// Used for home screen.
final void Function(Story, Rect?)? onMoreTapped;
@override
Widget build(BuildContext context) {
@ -71,39 +77,57 @@ class ItemsListView<T extends Item> extends StatelessWidget {
...items.map((T e) {
if (e is Story) {
final bool hasRead = context.read<StoriesBloc>().hasRead(e);
final bool swipeGestureEnabled =
context.read<PreferenceCubit>().state.swipeGestureEnabled;
return <Widget>[
FadeIn(
child: Slidable(
startActionPane: pinnable
? ActionPane(
motion: const BehindMotion(),
children: <Widget>[
SlidableAction(
onPressed: (_) {
HapticFeedback.lightImpact();
onPinned?.call(e);
},
backgroundColor: Palette.orange,
foregroundColor: Palette.white,
icon: showWebPreview
? Icons.push_pin_outlined
: null,
label: 'Pin to top',
),
],
)
: null,
child: StoryTile(
key: ValueKey<int>(e.id),
story: e,
onTap: () => onTap(e),
showWebPreview: showWebPreview,
showMetadata: showMetadata,
showUrl: showUrl,
hasRead: markReadStories && hasRead,
simpleTileFontSize: useConsistentFontSize
? TextDimens.pt14
: TextDimens.pt16,
GestureDetector(
/// If swipe gesture is enabled on home screen, use long press
/// instead of slide action to trigger the action menu.
onLongPress: swipeGestureEnabled
? () => onMoreTapped?.call(e, context.rect)
: null,
child: FadeIn(
child: Slidable(
enabled: !swipeGestureEnabled,
startActionPane: pinnable
? ActionPane(
motion: const BehindMotion(),
children: <Widget>[
SlidableAction(
onPressed: (_) {
HapticFeedback.lightImpact();
onPinned?.call(e);
},
backgroundColor: Palette.orange,
foregroundColor: Palette.white,
icon: showWebPreview
? Icons.push_pin_outlined
: null,
label: showWebPreview ? null : 'Pin to top',
),
SlidableAction(
onPressed: (_) =>
onMoreTapped?.call(e, context.rect),
backgroundColor: Palette.orange,
foregroundColor: Palette.white,
icon: showWebPreview ? Icons.more_horiz : null,
label: showWebPreview ? null : 'More',
),
],
)
: null,
child: StoryTile(
key: ValueKey<int>(e.id),
story: e,
onTap: () => onTap(e),
showWebPreview: showWebPreview,
showMetadata: showMetadata,
showUrl: showUrl,
hasRead: markReadStories && hasRead,
simpleTileFontSize: useConsistentFontSize
? TextDimens.pt14
: TextDimens.pt16,
),
),
),
),
@ -249,11 +273,10 @@ class ItemsListView<T extends Item> extends StatelessWidget {
class _CommentTile extends StatelessWidget {
const _CommentTile({
Key? key,
required this.comment,
required this.onTap,
this.fontSize = 16,
}) : super(key: key);
});
final Comment comment;
final VoidCallback onTap;

View File

@ -113,7 +113,8 @@ class LinkPreview extends StatefulWidget {
class _LinkPreviewState extends State<LinkPreview> {
InfoBase? _info;
String? _errorTitle, _errorBody;
String? _errorTitle;
String? _errorBody;
bool _loading = false;
@override
@ -158,7 +159,7 @@ class _LinkPreviewState extends State<LinkPreview> {
}
Widget _buildLinkContainer(
double _height, {
double height, {
String? title = '',
String? desc = '',
String? imageUri = '',
@ -177,7 +178,7 @@ class _LinkPreviewState extends State<LinkPreview> {
const BoxShadow(blurRadius: 3, color: Palette.grey),
],
),
height: _height,
height: height,
child: LinkView(
key: widget.key ?? Key(widget.link),
metadata: widget.story.simpleMetadata,

View File

@ -37,7 +37,7 @@ class LinkView extends StatelessWidget {
final String description;
final String? imageUri;
final String? imagePath;
final Function(String) onTap;
final void Function(String) onTap;
final TextStyle? titleTextStyle;
final TextStyle? bodyTextStyle;
final bool showMultiMedia;
@ -76,13 +76,13 @@ class LinkView extends StatelessWidget {
final double layoutWidth = constraints.biggest.width;
final double layoutHeight = constraints.biggest.height;
final TextStyle _titleFontSize = titleTextStyle ??
final TextStyle titleFontSize = titleTextStyle ??
TextStyle(
fontSize: computeTitleFontSize(layoutWidth),
color: Palette.black,
fontWeight: FontWeight.bold,
);
final TextStyle _bodyFontSize = bodyTextStyle ??
final TextStyle bodyFontSize = bodyTextStyle ??
TextStyle(
fontSize: computeTitleFontSize(layoutWidth) - 1,
color: Palette.grey,
@ -131,11 +131,11 @@ class LinkView extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
_buildTitleContainer(
_titleFontSize,
titleFontSize,
computeTitleLines(layoutHeight),
),
_buildBodyContainer(
_bodyFontSize,
bodyFontSize,
computeBodyLines(layoutHeight),
)
],
@ -149,7 +149,7 @@ class LinkView extends StatelessWidget {
);
}
Widget _buildTitleContainer(TextStyle _titleTS, int _maxLines) {
Widget _buildTitleContainer(TextStyle titleTS, int maxLines) {
final bool showUrl = this.showUrl && url.isNotEmpty;
return Padding(
padding: const EdgeInsets.fromLTRB(4, 2, 3, 0),
@ -159,9 +159,9 @@ class LinkView extends StatelessWidget {
alignment: Alignment.topLeft,
child: Text(
title,
style: _titleTS,
style: titleTS,
overflow: TextOverflow.ellipsis,
maxLines: _maxLines,
maxLines: maxLines,
),
),
if (showUrl)
@ -170,10 +170,10 @@ class LinkView extends StatelessWidget {
child: Text(
'($readableUrl)',
textAlign: TextAlign.left,
style: _titleTS.copyWith(
style: titleTS.copyWith(
color: Palette.grey,
fontSize:
_titleTS.fontSize == null ? 12 : _titleTS.fontSize! - 4,
titleTS.fontSize == null ? 12 : titleTS.fontSize! - 4,
fontWeight: FontWeight.w400,
),
overflow: bodyTextOverflow ?? TextOverflow.ellipsis,
@ -185,7 +185,7 @@ class LinkView extends StatelessWidget {
);
}
Widget _buildBodyContainer(TextStyle _bodyTS, int _maxLines) {
Widget _buildBodyContainer(TextStyle bodyTS, int maxLines) {
return Expanded(
flex: 2,
child: Padding(
@ -198,9 +198,9 @@ class LinkView extends StatelessWidget {
child: Text(
metadata,
textAlign: TextAlign.left,
style: _bodyTS.copyWith(
style: bodyTS.copyWith(
fontSize:
_bodyTS.fontSize == null ? 12 : _bodyTS.fontSize! - 2,
bodyTS.fontSize == null ? 12 : bodyTS.fontSize! - 2,
),
overflow: bodyTextOverflow ?? TextOverflow.ellipsis,
maxLines: 1,
@ -212,9 +212,9 @@ class LinkView extends StatelessWidget {
child: Text(
description,
textAlign: TextAlign.left,
style: _bodyTS,
style: bodyTS,
overflow: bodyTextOverflow ?? TextOverflow.ellipsis,
maxLines: (bodyMaxLines ?? _maxLines) -
maxLines: (bodyMaxLines ?? maxLines) -
(showMetadata ? 1 : 0) -
(showUrl && url.isNotEmpty ? 1 : 0),
),

View File

@ -63,7 +63,7 @@ class WebImageInfo extends InfoBase {
/// Video Information
class WebVideoInfo extends WebImageInfo {
WebVideoInfo({String? image}) : super(image: image);
WebVideoInfo({super.image});
}
/// Web analyzer

View File

@ -6,7 +6,7 @@ import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
class OnboardingView extends StatefulWidget {
const OnboardingView({Key? key}) : super(key: key);
const OnboardingView({super.key});
@override
State<OnboardingView> createState() => _OnboardingViewState();
@ -106,10 +106,9 @@ class _OnboardingViewState extends State<OnboardingView> {
class _PageViewChild extends StatelessWidget {
const _PageViewChild({
Key? key,
required this.path,
required this.description,
}) : super(key: key);
});
final String path;
final String description;

View File

@ -3,6 +3,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
@ -40,8 +41,8 @@ class _StoriesListViewState extends State<StoriesListView> {
return BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (PreferenceState previous, PreferenceState current) =>
previous.showComplexStoryTile != current.showComplexStoryTile ||
previous.showMetadata != current.showMetadata,
previous.complexStoryTileEnabled != current.complexStoryTileEnabled ||
previous.metadataEnabled != current.metadataEnabled,
builder: (BuildContext context, PreferenceState preferenceState) {
return BlocConsumer<StoriesBloc, StoriesState>(
listenWhen: (StoriesState previous, StoriesState current) =>
@ -65,10 +66,10 @@ class _StoriesListViewState extends State<StoriesListView> {
pinnable: true,
showOfflineBanner: true,
markReadStories:
context.read<PreferenceCubit>().state.markReadStories,
showWebPreview: preferenceState.showComplexStoryTile,
showMetadata: preferenceState.showMetadata,
showUrl: preferenceState.showUrl,
context.read<PreferenceCubit>().state.markReadStoriesEnabled,
showWebPreview: preferenceState.complexStoryTileEnabled,
showMetadata: preferenceState.metadataEnabled,
showUrl: preferenceState.urlEnabled,
refreshController: refreshController,
items: state.storiesByType[storyType]!,
onRefresh: () {
@ -86,6 +87,7 @@ class _StoriesListViewState extends State<StoriesListView> {
onTap: onStoryTapped,
onPinned: context.read<PinCubit>().pinStory,
header: state.offlineReading ? null : header,
onMoreTapped: onMoreTapped,
);
},
);

View File

@ -56,7 +56,7 @@ class StoryTile extends StatelessWidget {
titleStyle: TextStyle(
color: hasRead
? Palette.grey[500]
: Theme.of(context).textTheme.subtitle1?.color,
: Theme.of(context).textTheme.bodyLarge?.color,
fontWeight: FontWeight.bold,
),
showMetadata: showMetadata,
@ -90,7 +90,7 @@ class StoryTile extends StatelessWidget {
? Palette.grey[500]
: Theme.of(context)
.textTheme
.bodyText1
.bodyLarge
?.color,
fontSize: simpleTileFontSize,
),
@ -137,9 +137,8 @@ class StoryTile extends StatelessWidget {
class _LinkPreviewPlaceholder extends StatelessWidget {
const _LinkPreviewPlaceholder({
Key? key,
required this.height,
}) : super(key: key);
});
final double height;

View File

@ -4,9 +4,9 @@ export 'comment_tile.dart';
export 'countdown_reminder.dart';
export 'custom_chip.dart';
export 'custom_circular_progress_indicator.dart';
export 'custom_tab_bar.dart';
export 'items_list_view.dart';
export 'link_preview/link_preview.dart';
export 'link_preview/link_preview.dart';
export 'offline_banner.dart';
export 'onboarding_view.dart';
export 'spring_curve.dart';

View File

@ -8,7 +8,7 @@ class CustomBlocObserver extends BlocObserver {
@override
void onCreate(BlocBase<dynamic> bloc) {
if (bloc is! CollapseCubit) {
locator.get<Logger>().v('$bloc created');
locator.get<Logger>().d('$bloc created');
}
super.onCreate(bloc);
@ -20,22 +20,23 @@ class CustomBlocObserver extends BlocObserver {
Object? event,
) {
if (event is! StoriesEvent) {
locator.get<Logger>().v(event);
locator.get<Logger>().d(event);
}
super.onEvent(bloc, event);
}
@override
void onTransition(
Bloc<dynamic, dynamic> bloc,
Transition<dynamic, dynamic> transition,
) {
if (bloc is! StoriesBloc) {
locator.get<Logger>().v(transition);
void onChange(BlocBase<dynamic> bloc, Change<dynamic> change) {
if (bloc is! StoriesBloc &&
bloc is! CommentsCubit &&
bloc is! HistoryCubit &&
bloc is! FavCubit &&
bloc is! NotificationCubit) {
locator.get<Logger>().d(change);
}
super.onTransition(bloc, transition);
super.onChange(bloc, change);
}
@override

View File

@ -4,14 +4,13 @@ import 'dart:math';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/utils/html_util.dart';
import 'package:path_provider_android/path_provider_android.dart';
import 'package:path_provider_ios/path_provider_ios.dart';
import 'package:path_provider_foundation/path_provider_foundation.dart';
import 'package:shared_preferences_android/shared_preferences_android.dart';
import 'package:shared_preferences_ios/shared_preferences_ios.dart';
import 'package:shared_preferences_foundation/shared_preferences_foundation.dart';
import 'package:workmanager/workmanager.dart';
void fetcherCallbackDispatcher() {
@ -20,10 +19,9 @@ void fetcherCallbackDispatcher() {
if (Platform.isAndroid) {
PathProviderAndroid.registerWith();
SharedPreferencesAndroid.registerWith();
}
if (Platform.isIOS) {
PathProviderIOS.registerWith();
SharedPreferencesIOS.registerWith();
} else if (Platform.isIOS) {
PathProviderFoundation.registerWith();
SharedPreferencesFoundation.registerWith();
}
await Fetcher.fetchReplies();
@ -44,7 +42,6 @@ abstract class Fetcher {
final SembastRepository sembastRepository = SembastRepository();
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
final String happyFace = Constants.happyFaces.pickRandomly()!;
final String? username = await authRepository.username;
final List<int> unreadIds = await preferenceRepository.unreadCommentsIds;
@ -124,10 +121,10 @@ abstract class Fetcher {
await flutterLocalNotificationsPlugin.show(
newReply?.id ?? 0,
'You have a new reply! $happyFace',
'You have a new reply! ${Constants.happyFace}',
'${newReply?.by}: $text',
const NotificationDetails(
iOS: IOSNotificationDetails(
iOS: DarwinNotificationDetails(
presentBadge: false,
threadIdentifier: 'hacki',
),

View File

@ -2,14 +2,12 @@ import 'dart:convert';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
class LocalNotification {
Future<void> pushForNewReply(Comment newReply, int storyId) async {
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
final String happyFace = Constants.happyFaces.pickRandomly()!;
final Map<String, int> payloadJson = <String, int>{
'commentId': newReply.id,
@ -19,10 +17,10 @@ class LocalNotification {
return flutterLocalNotificationsPlugin.show(
newReply.id,
'You have a new reply! $happyFace',
'You have a new reply! ${Constants.happyFace}',
'${newReply.by}: ${newReply.text}',
const NotificationDetails(
iOS: IOSNotificationDetails(
iOS: DarwinNotificationDetails(
presentBadge: false,
threadIdentifier: 'hacki',
),

View File

@ -38,3 +38,8 @@ abstract class TextDimens {
static const double pt26 = 26;
static const double pt36 = 36;
}
abstract class NumSwitch {
static const double on = 1;
static const double off = 0;
}

View File

@ -13,6 +13,16 @@ import 'package:url_launcher/url_launcher.dart';
abstract class LinkUtil {
static final ChromeSafariBrowser _browser = ChromeSafariBrowser();
static void launchInExternalBrowser(
String link,
) {
final Uri uri = Uri.parse(link);
launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
}
static void launch(
String link, {
bool useReader = false,

54
lib/utils/log_util.dart Normal file
View File

@ -0,0 +1,54 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/file_output.dart';
import 'package:logger/logger.dart';
import 'package:path_provider/path_provider.dart';
abstract class LogUtil {
static LogPrinter get logPrinter => kReleaseMode
? SimplePrinter(colors: false)
: PrettyPrinter(
methodCount: 0,
colors: false,
);
static LogOutput getLogOutput(File outputFile) => MultiOutput(
<LogOutput>[
ConsoleOutput(),
CustomFileOutput(
file: outputFile,
overrideExisting: true,
),
],
);
static Future<File> initLogFile() async {
final Directory tempDir = await getTemporaryDirectory();
final File logFile = File('${tempDir.path}/${Constants.logFilename}');
if (logFile.existsSync()) {
await logFile.rename('${tempDir.path}/${Constants.previousLogFileName}');
}
return logFile;
}
static Future<File> exportLog() async {
final Directory tempDir = await getTemporaryDirectory();
final String logPath = '${tempDir.path}/${Constants.logFilename}';
final String previousLogPath =
'${tempDir.path}/${Constants.previousLogFileName}';
final File currentSessionLog = File(logPath);
final File previousSessionLog = File(previousLogPath);
final Uint8List fileContent = await currentSessionLog.readAsBytes();
await previousSessionLog.writeAsString(
'Current session logs:',
mode: FileMode.append,
);
return previousSessionLog.writeAsBytes(
fileContent,
mode: FileMode.append,
);
}
}

View File

@ -1,5 +1,6 @@
export 'debouncer.dart';
export 'html_util.dart';
export 'link_util.dart';
export 'log_util.dart';
export 'service_exception.dart';
export 'throttle.dart';

File diff suppressed because it is too large Load Diff

View File

@ -1,20 +1,20 @@
name: hacki
description: A Hacker News reader.
version: 1.0.2+80
version: 1.0.8+86
publish_to: none
environment:
sdk: ">=2.17.0 <3.0.0"
flutter: "3.3.10"
flutter: "3.7.2"
dependencies:
adaptive_theme: ^3.0.0
badges: ^2.0.2
badges: ^3.0.2
bloc: ^8.1.0
cached_network_image: ^3.2.1
clipboard: ^0.1.3
collection: ^1.16.0
connectivity_plus: ^2.3.7
collection: ^1.17.0
connectivity_plus: ^3.0.2
dio: ^4.0.4
equatable: ^2.0.5
fast_gbk: ^1.0.0
@ -26,15 +26,16 @@ dependencies:
sdk: flutter
flutter_bloc: ^8.1.1
flutter_cache_manager: ^3.3.0
flutter_email_sender: ^5.2.0
flutter_fadein: ^2.0.0
flutter_feather_icons: 2.0.0+1
flutter_inappwebview: ^5.7.2+3
flutter_linkify: ^5.0.2
flutter_local_notifications: ^9.5.0
flutter_secure_storage: ^6.0.0
flutter_local_notifications: ^13.0.0
flutter_secure_storage: ^7.0.1
flutter_siri_suggestions: ^2.1.0
flutter_slidable: ^2.0.0
font_awesome_flutter: ^9.2.0
font_awesome_flutter: ^10.3.0
gbk_codec: ^0.4.0
get_it: 7.2.0
hive: ^2.0.6
@ -42,25 +43,25 @@ dependencies:
html_unescape: ^2.0.0
http: ^0.13.3
hydrated_bloc: ^9.0.0-dev.3
intl: ^0.17.0
intl: ^0.18.0
logger: ^1.1.0
package_info_plus: ^3.0.2
path: ^1.8.0
path: ^1.8.2
path_provider: ^2.0.8
path_provider_android: ^2.0.8
path_provider_ios: ^2.0.8
path_provider_foundation: ^2.1.1
pull_to_refresh:
git:
url: https://github.com/livinglist/flutter_pulltorefresh
ref: master
receive_sharing_intent: ^1.4.5
responsive_builder: ^0.4.2
responsive_builder: ^0.5.0+1
rxdart: ^0.27.3
sembast: ^3.1.1+1
share_plus: ^4.0.8
shared_preferences: ^2.0.11
shared_preferences_android: ^2.0.11
shared_preferences_ios: ^2.0.11
share_plus: ^6.3.0
shared_preferences: ^2.0.17
shared_preferences_android: ^2.0.15
shared_preferences_foundation: ^2.1.2
shimmer: ^2.0.0
synced_shared_preferences:
path: components/synced_shared_preferences
@ -68,17 +69,19 @@ dependencies:
universal_platform: ^1.0.0+1
url_launcher: ^6.1.3
wakelock: ^0.6.1+2
webview_flutter: ^3.0.4
webview_flutter: ^4.0.2
workmanager: ^0.5.0
dev_dependencies:
bloc_test: ^9.1.0
flutter_driver:
sdk: flutter
flutter_test:
sdk: flutter
integration_test:
sdk: flutter
mocktail: ^0.3.0
very_good_analysis: ^2.4.0
very_good_analysis: ^3.1.0
flutter:
uses-material-design: true

View File

@ -20,8 +20,11 @@ void main() {
final MockStoriesRepository mockStoriesRepository = MockStoriesRepository();
final MockSembastRepository mockSembastRepository = MockSembastRepository();
const int created = 0, delay = 1, karma = 2;
const String about = 'about', id = 'id';
const int created = 0;
const int delay = 1;
const int karma = 2;
const String about = 'about';
const String id = 'id';
const User tUser = User(
about: about,
@ -57,7 +60,8 @@ void main() {
);
group('AuthAppStarted', () {
const String username = 'username', password = 'password';
const String username = 'username';
const String password = 'password';
setUp(() {
when(() => mockAuthRepository.username)
.thenAnswer((_) => Future<String?>.value(username));