Compare commits

...

13 Commits

Author SHA1 Message Date
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
64 changed files with 2509 additions and 1958 deletions

View File

@ -12,12 +12,12 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 30
env:
FLUTTER_VERSION: "3.7.0"
FLUTTER_VERSION: "3.7.1"
steps:
- uses: actions/checkout@v2
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.7.0'
flutter-version: '3.7.1'
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.7.0
flutter-version: 3.7.1
- run: flutter pub get
- run: flutter format --set-exit-if-changed .
- run: flutter analyze

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)
@ -27,8 +29,6 @@ PODS:
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- path_provider_ios (0.0.1):
- Flutter
- ReachabilitySwift (5.0.0)
- receive_sharing_intent (0.0.1):
- Flutter
@ -37,8 +37,6 @@ PODS:
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- shared_preferences_ios (0.0.1):
- Flutter
- sqflite (0.0.2):
- Flutter
- FMDB (>= 2.7.5)
@ -56,6 +54,7 @@ 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`)
@ -63,11 +62,9 @@ DEPENDENCIES:
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/ios`)
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/ios`)
- shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/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`)
@ -86,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:
@ -100,16 +99,12 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/ios"
path_provider_ios:
:path: ".symlinks/plugins/path_provider_ios/ios"
receive_sharing_intent:
:path: ".symlinks/plugins/receive_sharing_intent/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/ios"
shared_preferences_ios:
:path: ".symlinks/plugins/shared_preferences_ios/ios"
sqflite:
:path: ".symlinks/plugins/sqflite/ios"
synced_shared_preferences:
@ -126,6 +121,7 @@ EXTERNAL SOURCES:
SPEC CHECKSUMS:
connectivity_plus: 413a8857dd5d9f1c399a39130850d02fe0feaf7e
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
flutter_email_sender: 02d7443217d8c41483223627972bfdc09f74276b
flutter_inappwebview: bfd58618f49dc62f2676de690fc6dcda1d6c3721
flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
@ -135,12 +131,10 @@ SPEC CHECKSUMS:
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
path_provider_foundation: 37748e03f12783f9de2cb2c4eadfaa25fe6d4852
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68
shared_preferences_foundation: 297b3ebca31b34ec92be11acd7fb0ba932c822ca
shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
synced_shared_preferences: f722742b06d65c7315b8e9f56b794c9fbd5597f7
url_launcher_ios: ae1517e5e344f5544fb090b079e11f399dfbe4d2

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

@ -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,11 +79,13 @@ 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,
@ -299,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) {
@ -332,11 +353,13 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
.fetchAllChildrenComments(ids: story.kids)
.whereType<Comment>()
.listen(
(Comment comment) => unawaited(
(Comment comment) {
_logger.d('fetched comment ${comment.id}');
unawaited(
_offlineRepository.cacheComment(comment: comment),
),
)
.onDone(() => add(StoryDownloaded(skipped: false)));
);
},
).onDone(() => add(StoryDownloaded(skipped: false)));
}
}

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

@ -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({
@ -44,4 +54,169 @@ 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);
}
}
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 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>>[
static final List<Preference<dynamic>> allPreferences =
UnmodifiableListView<Preference<dynamic>>(
<Preference<dynamic>>[
// Order of these first four preferences does not matter.
FetchModePreference(),
CommentsOrderPreference(),
FontSizePreference(),
// order here reflects the order on settings screen.
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

@ -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,616 +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' hide Badge;
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: ColoredBox(
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({
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(),
),
],
);
}
}
class _TabletHomeScreen extends StatelessWidget {
const _TabletHomeScreen({
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

@ -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

@ -2,7 +2,6 @@ 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';
@ -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

@ -135,7 +135,7 @@ class MainView extends StatelessWidget {
return SizedBox(
height: _trailingBoxHeight,
child: Center(
child: Text(Constants.happyFaces.pickRandomly()!),
child: Text(Constants.happyFace),
),
);
} else {
@ -324,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

View File

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

View File

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

View File

@ -1,12 +1,6 @@
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_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';
@ -15,23 +9,15 @@ 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/profile/models/models.dart';
import 'package:hacki/screens/profile/widgets/widgets.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'package:tuple/tuple.dart';
enum _PageType {
fav,
history,
settings,
search,
notification,
}
class ProfileScreen extends StatefulWidget {
const ProfileScreen({super.key});
@ -47,7 +33,7 @@ class _ProfileScreenState extends State<ProfileScreen>
final ScrollController scrollController = ScrollController();
final Throttle throttle = Throttle(delay: const Duration(seconds: 2));
_PageType pageType = _PageType.notification;
PageType pageType = PageType.notification;
final List<String> magicWords = <String>[
'to be a lord.',
@ -72,13 +58,10 @@ class _ProfileScreenState extends State<ProfileScreen>
Widget build(BuildContext context) {
super.build(context);
final String magicWord = (magicWords..shuffle()).first;
return BlocBuilder<PreferenceCubit, PreferenceState>(
builder: (BuildContext context, PreferenceState preferenceState) {
return BlocBuilder<AuthBloc, AuthState>(
builder: (BuildContext context, AuthState authState) {
return BlocConsumer<NotificationCubit, NotificationState>(
listenWhen:
(NotificationState previous, NotificationState current) =>
listenWhen: (NotificationState previous, NotificationState current) =>
previous.status != current.status,
listener:
(BuildContext context, NotificationState notificationState) {
@ -88,14 +71,13 @@ class _ProfileScreenState extends State<ProfileScreen>
..loadComplete();
}
},
builder:
(BuildContext context, NotificationState notificationState) {
builder: (BuildContext context, NotificationState notificationState) {
return Stack(
children: <Widget>[
Positioned.fill(
top: Dimens.pt50,
child: Visibility(
visible: pageType == _PageType.history,
visible: pageType == PageType.history,
child: BlocConsumer<HistoryCubit, HistoryState>(
listener: (
BuildContext context,
@ -153,7 +135,7 @@ class _ProfileScreenState extends State<ProfileScreen>
Positioned.fill(
top: Dimens.pt50,
child: Visibility(
visible: pageType == _PageType.fav,
visible: pageType == PageType.fav,
child: BlocConsumer<FavCubit, FavState>(
listener: (BuildContext context, FavState favState) {
if (favState.status == FavStatus.loaded) {
@ -166,17 +148,30 @@ class _ProfileScreenState extends State<ProfileScreen>
if (favState.favItems.isEmpty &&
favState.status != FavStatus.loading) {
return const CenteredMessageView(
content:
'Your favorite stories will show up here.'
content: 'Your favorite stories will show up here.'
'\nThey will be synced to your Hacker '
'News account if you are logged in.',
);
}
return BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (
PreferenceState previous,
PreferenceState current,
) =>
previous.complexStoryTileEnabled !=
current.complexStoryTileEnabled ||
previous.metadataEnabled !=
current.metadataEnabled ||
previous.urlEnabled != current.urlEnabled,
builder: (
BuildContext context,
PreferenceState prefState,
) {
return ItemsListView<Item>(
showWebPreview:
preferenceState.showComplexStoryTile,
showMetadata: preferenceState.showMetadata,
showUrl: preferenceState.showUrl,
showWebPreview: prefState.complexStoryTileEnabled,
showMetadata: prefState.metadataEnabled,
showUrl: prefState.urlEnabled,
useCommentTile: true,
refreshController: refreshControllerFav,
items: favState.favItems,
@ -192,13 +187,15 @@ class _ProfileScreenState extends State<ProfileScreen>
),
);
},
);
},
),
),
),
Positioned.fill(
top: Dimens.pt50,
child: Visibility(
visible: pageType == _PageType.search,
visible: pageType == PageType.search,
maintainState: true,
child: const SearchScreen(),
),
@ -206,16 +203,14 @@ class _ProfileScreenState extends State<ProfileScreen>
Positioned.fill(
top: Dimens.pt50,
child: Visibility(
visible: pageType == _PageType.notification,
visible: pageType == PageType.notification,
child: notificationState.comments.isEmpty
? const CenteredMessageView(
content:
'New replies to your comments or stories '
content: 'New replies to your comments or stories '
'will show up here.',
)
: InboxView(
refreshController:
refreshControllerNotification,
refreshController: refreshControllerNotification,
unreadCommentsIds:
notificationState.unreadCommentsIds,
comments: notificationState.comments,
@ -230,9 +225,7 @@ class _ProfileScreenState extends State<ProfileScreen>
);
},
onMarkAllAsReadTapped: () {
context
.read<NotificationCubit>()
.markAllAsRead();
context.read<NotificationCubit>().markAllAsRead();
},
onLoadMore: () {
context.read<NotificationCubit>().loadMore();
@ -244,207 +237,11 @@ class _ProfileScreenState extends State<ProfileScreen>
),
),
),
Positioned.fill(
top: Dimens.pt50,
child: Visibility(
visible: pageType == _PageType.settings,
child: SingleChildScrollView(
child: Column(
children: <Widget>[
ListTile(
title: Text(
authState.isLoggedIn ? 'Log Out' : 'Log In',
),
subtitle: Text(
authState.isLoggedIn
? authState.username
: magicWord,
),
onTap: () {
if (authState.isLoggedIn) {
onLogoutTapped();
} else {
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) {
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) {
context
.read<PreferenceCubit>()
.update(
CommentsOrderPreference(),
to: order.index,
);
}
},
),
const Spacer(),
],
),
),
],
),
const Divider(),
StoryTile(
showWebPreview:
preferenceState.showComplexStoryTile,
showMetadata: preferenceState.showMetadata,
showUrl: preferenceState.showUrl,
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,
),
],
),
),
),
Settings(
authState: authState,
magicWord: magicWord,
pageType: pageType,
onLoginTapped: onLoginTapped,
),
Align(
alignment: Alignment.topLeft,
@ -456,6 +253,7 @@ class _ProfileScreenState extends State<ProfileScreen>
const SizedBox(
width: Dimens.pt12,
),
if (authState.isLoggedIn) ...<Widget>[
CustomChip(
label: 'Submit',
selected: false,
@ -479,11 +277,11 @@ class _ProfileScreenState extends State<ProfileScreen>
label: 'Inbox : '
// ignore: lines_longer_than_80_chars
'${notificationState.unreadCommentsIds.length}',
selected: pageType == _PageType.notification,
selected: pageType == PageType.notification,
onSelected: (bool val) {
if (val) {
setState(() {
pageType = _PageType.notification;
pageType = PageType.notification;
});
}
},
@ -491,13 +289,14 @@ class _ProfileScreenState extends State<ProfileScreen>
const SizedBox(
width: Dimens.pt12,
),
],
CustomChip(
label: 'Favorite',
selected: pageType == _PageType.fav,
selected: pageType == PageType.fav,
onSelected: (bool val) {
if (val) {
setState(() {
pageType = _PageType.fav;
pageType = PageType.fav;
});
}
},
@ -505,13 +304,14 @@ class _ProfileScreenState extends State<ProfileScreen>
const SizedBox(
width: Dimens.pt12,
),
if (authState.isLoggedIn) ...<Widget>[
CustomChip(
label: 'Submitted',
selected: pageType == _PageType.history,
selected: pageType == PageType.history,
onSelected: (bool val) {
if (val) {
setState(() {
pageType = _PageType.history;
pageType = PageType.history;
});
}
},
@ -519,13 +319,14 @@ class _ProfileScreenState extends State<ProfileScreen>
const SizedBox(
width: Dimens.pt12,
),
],
CustomChip(
label: 'Search',
selected: pageType == _PageType.search,
selected: pageType == PageType.search,
onSelected: (bool val) {
if (val) {
setState(() {
pageType = _PageType.search;
pageType = PageType.search;
});
}
},
@ -535,11 +336,11 @@ class _ProfileScreenState extends State<ProfileScreen>
),
CustomChip(
label: 'Settings',
selected: pageType == _PageType.settings,
selected: pageType == PageType.settings,
onSelected: (bool val) {
if (val) {
setState(() {
pageType = _PageType.settings;
pageType = PageType.settings;
});
}
},
@ -557,186 +358,6 @@ class _ProfileScreenState extends State<ProfileScreen>
);
},
);
},
);
}
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: () => 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 the app?'),
],
),
),
ElevatedButton(
onPressed: () => LinkUtil.launch(
Constants.sponsorLink,
),
child: Row(
children: const <Widget>[
Icon(
FeatherIcons.coffee,
),
SizedBox(
width: Dimens.pt12,
),
Text('Buy me a coffee'),
],
),
),
],
);
}
}
void onCommentTapped(Comment comment, {VoidCallback? then}) {
@ -950,43 +571,6 @@ class _ProfileScreenState extends State<ProfileScreen>
);
}
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',
),
),
],
);
},
);
}
@override
bool get wantKeepAlive => true;
}

View File

@ -58,8 +58,32 @@ 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>(
@ -94,6 +118,7 @@ class OfflineListTile extends StatelessWidget {
});
}
});
}
},
);
},

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,16 +164,16 @@ 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,
),

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

@ -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',
@ -59,6 +62,9 @@ class ItemsListView<T extends Item> extends StatelessWidget {
final ValueChanged<Story>? onPinned;
final void Function(T) onTap;
/// Used for home screen.
final void Function(Story, Rect?)? onMoreTapped;
@override
Widget build(BuildContext context) {
final ListView child = ListView(
@ -71,9 +77,18 @@ 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(
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(),
@ -88,7 +103,15 @@ class ItemsListView<T extends Item> extends StatelessWidget {
icon: showWebPreview
? Icons.push_pin_outlined
: null,
label: 'Pin to top',
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',
),
],
)
@ -107,6 +130,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
),
),
),
),
if (!showWebPreview)
const Divider(
height: Dimens.zero,

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

@ -4,6 +4,7 @@ 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 'offline_banner.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

@ -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';

View File

@ -53,10 +53,10 @@ packages:
dependency: "direct main"
description:
name: badges
sha256: "727580d938b7a1ff47ea42df730d581415606b4224cfa708671c10287f8d3fe6"
sha256: "461031a60efbb95276f52107f63d5d45008b5ca1eb7f8ca440cadda9ec2143b0"
url: "https://pub.dev"
source: hosted
version: "2.0.3"
version: "3.0.2"
bloc:
dependency: "direct main"
description:
@ -141,26 +141,10 @@ packages:
dependency: "direct main"
description:
name: connectivity_plus
sha256: "3f8fe4e504c2d33696dac671a54909743bc6a902a9bb0902306f7a2aed7e528e"
sha256: "745ebcccb1ef73768386154428a55250bc8d44059c19fd27aecda2a6dc013a22"
url: "https://pub.dev"
source: hosted
version: "2.3.9"
connectivity_plus_linux:
dependency: transitive
description:
name: connectivity_plus_linux
sha256: "3caf859d001f10407b8e48134c761483e4495ae38094ffcca97193f6c271f5e2"
url: "https://pub.dev"
source: hosted
version: "1.3.1"
connectivity_plus_macos:
dependency: transitive
description:
name: connectivity_plus_macos
sha256: "488d2de1e47e1224ad486e501b20b088686ba1f4ee9c4420ecbc3b9824f0b920"
url: "https://pub.dev"
source: hosted
version: "1.2.6"
version: "3.0.2"
connectivity_plus_platform_interface:
dependency: transitive
description:
@ -169,22 +153,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.3"
connectivity_plus_web:
dependency: transitive
description:
name: connectivity_plus_web
sha256: "81332be1b4baf8898fed17bb4fdef27abb7c6fd990bf98c54fd978478adf2f1a"
url: "https://pub.dev"
source: hosted
version: "1.2.5"
connectivity_plus_windows:
dependency: transitive
description:
name: connectivity_plus_windows
sha256: "535b0404b4d5605c4dd8453d67e5d6d2ea0dd36e3b477f50f31af51b0aeab9dd"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
convert:
dependency: transitive
description:
@ -332,6 +300,14 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
flutter_email_sender:
dependency: "direct main"
description:
name: flutter_email_sender
sha256: "9e253c69617f43d4cb5de672e93a7a19c12a21fb6a75e66c6ce7626336c4c1bc"
url: "https://pub.dev"
source: hosted
version: "5.2.0"
flutter_fadein:
dependency: "direct main"
description:
@ -368,34 +344,34 @@ packages:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: "57d0012730780fe137260dd180e072c18a73fbeeb924cdc029c18aaa0f338d64"
sha256: "293995f94e120c8afce768981bd1fa9c5d6de67c547568e3b42ae2defdcbb4a0"
url: "https://pub.dev"
source: hosted
version: "9.9.1"
version: "13.0.0"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: b472bfc173791b59ede323661eae20f7fff0b6908fea33dd720a6ef5d576bae8
sha256: "8f6c1611e0c4a88a382691a97bb3c3feb24cc0c0b54152b8b5fb7ffb837f7fbf"
url: "https://pub.dev"
source: hosted
version: "0.5.1"
version: "3.0.0"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: "21bceee103a66a53b30ea9daf677f990e5b9e89b62f222e60dd241cd08d63d3a"
sha256: "5ec1feac5f7f7d9266759488bc5f76416152baba9aa1b26fe572246caa00d1ab"
url: "https://pub.dev"
source: hosted
version: "5.0.0"
version: "6.0.0"
flutter_secure_storage:
dependency: "direct main"
description:
name: flutter_secure_storage
sha256: de957362e046bc68da8dcf6c1d922cb8bdad8dd4979ec69480cf1a3c481abe8e
sha256: f2afec1f1762c040a349ea2a588e32f442da5d0db3494a52a929a97c9e550bc5
url: "https://pub.dev"
source: hosted
version: "6.1.0"
version: "7.0.1"
flutter_secure_storage_linux:
dependency: transitive
description:
@ -408,10 +384,10 @@ packages:
dependency: transitive
description:
name: flutter_secure_storage_macos
sha256: "388f76fd0f093e7415a39ec4c169ae7cceeee6d9f9ba529d788a13f2be4de7bd"
sha256: ff0768a6700ea1d9620e03518e2e25eac86a8bd07ca3556e9617bfa5ace4bd00
url: "https://pub.dev"
source: hosted
version: "1.1.2"
version: "2.0.1"
flutter_secure_storage_platform_interface:
dependency: transitive
description:
@ -466,10 +442,10 @@ packages:
dependency: "direct main"
description:
name: font_awesome_flutter
sha256: "1f93e5799f0e6c882819e8393a05c6ca5226010f289190f2242ec19f3f0fdba5"
sha256: "875dbb9ec1ad30d68102019ceb682760d06c72747c1c5b7885781b95f88569cc"
url: "https://pub.dev"
source: hosted
version: "9.2.0"
version: "10.3.0"
frontend_server_client:
dependency: transitive
description:
@ -572,10 +548,10 @@ packages:
dependency: "direct main"
description:
name: intl
sha256: "910f85bce16fb5c6f614e117efa303e85a1731bb0081edf3604a2ae6e9a3cc91"
sha256: a3715e3bc90294e971cb7dc063fbf3cd9ee0ebf8604ffeafabd9e6f16abbdbe6
url: "https://pub.dev"
source: hosted
version: "0.17.0"
version: "0.18.0"
io:
dependency: transitive
description:
@ -612,10 +588,10 @@ packages:
dependency: transitive
description:
name: logging
sha256: c0bbfe94d46aedf9b8b3e695cf3bd48c8e14b35e3b2c639e0aa7755d589ba946
sha256: "04094f2eb032cbb06c6f6e8d3607edcfcb0455e2bb6cbc010cb01171dcb64e6d"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
version: "1.1.1"
matcher:
dependency: transitive
description:
@ -737,21 +713,13 @@ packages:
source: hosted
version: "2.0.22"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "6637955e38a5f1851c023482c25a60c93972ea06c8608e2f25ad0064c46c0939"
url: "https://pub.dev"
source: hosted
version: "2.1.0"
path_provider_ios:
dependency: "direct main"
description:
name: path_provider_ios
sha256: "03d639406f5343478352433f00d3c4394d52dac8df3d847869c5e2333e0bbce8"
name: path_provider_foundation
sha256: "62a68e7e1c6c459f9289859e2fae58290c981ce21d1697faf54910fe1faa4c74"
url: "https://pub.dev"
source: hosted
version: "2.0.11"
version: "2.1.1"
path_provider_linux:
dependency: transitive
description:
@ -861,10 +829,10 @@ packages:
dependency: "direct main"
description:
name: responsive_builder
sha256: f01bc341c73b6db7bd6319e22d2c160f28f924399ae46e6699ecc8160ba2765c
sha256: "0f082dff291f5ee4b4ef713d7d1e2a242b126204559024de07039aa7d9012aa5"
url: "https://pub.dev"
source: hosted
version: "0.4.3"
version: "0.5.0+1"
rxdart:
dependency: "direct main"
description:
@ -885,26 +853,10 @@ packages:
dependency: "direct main"
description:
name: share_plus
sha256: f582d5741930f3ad1bf0211d358eddc0508cc346e5b4b248bd1e569c995ebb7a
sha256: e387077716f80609bb979cd199331033326033ecd1c8f200a90c5f57b1c9f55e
url: "https://pub.dev"
source: hosted
version: "4.5.3"
share_plus_linux:
dependency: transitive
description:
name: share_plus_linux
sha256: dc32bf9f1151b9864bb86a997c61a487967a08f2e0b4feaa9a10538712224da4
url: "https://pub.dev"
source: hosted
version: "3.0.1"
share_plus_macos:
dependency: transitive
description:
name: share_plus_macos
sha256: "44daa946f2845045ecd7abb3569b61cd9a55ae9cc4cbec9895b2067b270697ae"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
version: "6.3.0"
share_plus_platform_interface:
dependency: transitive
description:
@ -913,22 +865,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.2.0"
share_plus_web:
dependency: transitive
description:
name: share_plus_web
sha256: eaef05fa8548b372253e772837dd1fbe4ce3aca30ea330765c945d7d4f7c9935
url: "https://pub.dev"
source: hosted
version: "3.1.0"
share_plus_windows:
dependency: transitive
description:
name: share_plus_windows
sha256: "3a21515ae7d46988d42130cd53294849e280a5de6ace24bae6912a1bffd757d4"
url: "https://pub.dev"
source: hosted
version: "3.0.1"
shared_preferences:
dependency: "direct main"
description:
@ -946,21 +882,13 @@ packages:
source: hosted
version: "2.0.15"
shared_preferences_foundation:
dependency: transitive
dependency: "direct main"
description:
name: shared_preferences_foundation
sha256: "1ffa239043ab8baf881ec3094a3c767af9d10399b2839020b9e4d44c0bb23951"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
shared_preferences_ios:
dependency: "direct main"
description:
name: shared_preferences_ios
sha256: "585a14cefec7da8c9c2fb8cd283a3bb726b4155c0952afe6a0caaa7b2272de34"
url: "https://pub.dev"
source: hosted
version: "2.1.1"
shared_preferences_linux:
dependency: transitive
description:
@ -1066,18 +994,18 @@ packages:
dependency: transitive
description:
name: sqflite
sha256: "067ab48dbc66bae05e18073a604443baa35957101bd3905b94f65e764c6d0688"
sha256: "78324387dc81df14f78df06019175a86a2ee0437624166c382e145d0a7fd9a4f"
url: "https://pub.dev"
source: hosted
version: "2.2.3"
version: "2.2.4+1"
sqflite_common:
dependency: transitive
description:
name: sqflite_common
sha256: b2ed22d1d62c944ec0dac5cc687ae99cb3331c3ebe146d726ed24704634b5ccd
sha256: bfd6973aaeeb93475bc0d875ac9aefddf7965ef22ce09790eb963992ffc5183f
url: "https://pub.dev"
source: hosted
version: "2.4.1"
version: "2.4.2+2"
stack_trace:
dependency: transitive
description:
@ -1161,10 +1089,10 @@ packages:
dependency: transitive
description:
name: timezone
sha256: "57b35f6e8ef731f18529695bffc62f92c6189fac2e52c12d478dec1931afb66e"
sha256: "24c8fcdd49a805d95777a39064862133ff816ebfffe0ceff110fb5960e557964"
url: "https://pub.dev"
source: hosted
version: "0.8.0"
version: "0.9.1"
tuple:
dependency: "direct main"
description:
@ -1249,10 +1177,10 @@ packages:
dependency: transitive
description:
name: url_launcher_windows
sha256: "387e227c4b979034cc52afb11d66b04ed9b288ca1f45beeef39b2ea69e714fa5"
sha256: b6217370f8eb1fd85c8890c539f5a639a01ab209a36db82c921ebeacefc7a615
url: "https://pub.dev"
source: hosted
version: "3.0.2"
version: "3.0.3"
uuid:
dependency: transitive
description:
@ -1361,34 +1289,34 @@ packages:
dependency: "direct main"
description:
name: webview_flutter
sha256: "392c1d83b70fe2495de3ea2c84531268d5b8de2de3f01086a53334d8b6030a88"
sha256: f7ec234830f86d0ef2bd664e8460b0038b8c1a83ff076035cad74ac70273753c
url: "https://pub.dev"
source: hosted
version: "3.0.4"
version: "4.0.2"
webview_flutter_android:
dependency: transitive
description:
name: webview_flutter_android
sha256: "8b3b2450e98876c70bfcead876d9390573b34b9418c19e28168b74f6cb252dbd"
sha256: "9d97fa2bae0f1900553c48a2ef0aaa3864367fd7bb625d683c460754b691312c"
url: "https://pub.dev"
source: hosted
version: "2.10.4"
version: "3.2.1"
webview_flutter_platform_interface:
dependency: transitive
description:
name: webview_flutter_platform_interface
sha256: "812165e4e34ca677bdfbfa58c01e33b27fd03ab5fa75b70832d4b7d4ca1fa8cf"
sha256: "8b2262dda5d26eabc600a7282a8c16a9473a0c765526afb0ffc33eef912f7968"
url: "https://pub.dev"
source: hosted
version: "1.9.5"
version: "2.0.1"
webview_flutter_wkwebview:
dependency: transitive
description:
name: webview_flutter_wkwebview
sha256: a5364369c758892aa487cbf59ea41d9edd10f9d9baf06a94e80f1bd1b4c7bbc0
sha256: "523aff9168af9bb2170e4809e0499d7dee065c3919799fd3341d3e616c137960"
url: "https://pub.dev"
source: hosted
version: "2.9.5"
version: "3.0.2"
win32:
dependency: transitive
description:
@ -1430,5 +1358,5 @@ packages:
source: hosted
version: "3.1.1"
sdks:
dart: ">=2.18.0 <4.0.0"
flutter: ">=3.7.0"
dart: ">=2.18.0 <3.0.0"
flutter: ">=3.7.1"

View File

@ -1,20 +1,20 @@
name: hacki
description: A Hacker News reader.
version: 1.0.3+81
version: 1.0.6+84
publish_to: none
environment:
sdk: ">=2.17.0 <3.0.0"
flutter: "3.7.0"
flutter: "3.7.1"
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,7 +69,7 @@ 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: