mirror of
https://github.com/Livinglist/Hacki.git
synced 2025-08-06 18:24:42 +08:00
Compare commits
17 Commits
Author | SHA1 | Date | |
---|---|---|---|
c7824eaef3 | |||
c2b66d29c3 | |||
e0a53e44b2 | |||
4cf8379db0 | |||
c1c26bf0e0 | |||
29e2f4163d | |||
c3de80015d | |||
436cd9ce8b | |||
efb326be68 | |||
047903fe24 | |||
41068ddf89 | |||
196516ce85 | |||
7f647b127d | |||
a50a0874e7 | |||
b176be96fb | |||
1e5af07691 | |||
ecf8c902dc |
4
.github/workflows/commit_check.yml
vendored
4
.github/workflows/commit_check.yml
vendored
@ -12,12 +12,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
FLUTTER_VERSION: "3.3.10"
|
||||
FLUTTER_VERSION: "3.7.2"
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: subosito/flutter-action@v2
|
||||
with:
|
||||
flutter-version: '3.3.10'
|
||||
flutter-version: '3.7.2'
|
||||
channel: 'stable'
|
||||
- run: flutter pub get
|
||||
- run: flutter format --set-exit-if-changed .
|
||||
|
2
.github/workflows/publish_ios.yml
vendored
2
.github/workflows/publish_ios.yml
vendored
@ -31,7 +31,7 @@ jobs:
|
||||
uses: subosito/flutter-action@v2
|
||||
with:
|
||||
cache: true
|
||||
flutter-version: 3.3.10
|
||||
flutter-version: 3.7.2
|
||||
- run: flutter pub get
|
||||
- run: flutter format --set-exit-if-changed .
|
||||
- run: flutter analyze
|
||||
|
@ -1,4 +1,4 @@
|
||||
include: package:very_good_analysis/analysis_options.2.4.0.yaml
|
||||
include: package:very_good_analysis/analysis_options.3.1.0.yaml
|
||||
linter:
|
||||
rules:
|
||||
parameter_assignments: false
|
||||
|
@ -1,4 +1,4 @@
|
||||
include: package:very_good_analysis/analysis_options.2.4.0.yaml
|
||||
include: package:very_good_analysis/analysis_options.3.1.0.yaml
|
||||
linter:
|
||||
rules:
|
||||
parameter_assignments: false
|
||||
|
0
fastlane/metadata/android/en-US/changelogs/81.txt
Normal file
0
fastlane/metadata/android/en-US/changelogs/81.txt
Normal file
3
fastlane/metadata/android/en-US/changelogs/84.txt
Normal file
3
fastlane/metadata/android/en-US/changelogs/84.txt
Normal 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.
|
@ -3,6 +3,8 @@ PODS:
|
||||
- Flutter
|
||||
- ReachabilitySwift
|
||||
- Flutter (1.0.0)
|
||||
- flutter_email_sender (0.0.1):
|
||||
- Flutter
|
||||
- flutter_inappwebview (0.0.1):
|
||||
- Flutter
|
||||
- flutter_inappwebview/Core (= 0.0.1)
|
||||
@ -12,7 +14,7 @@ PODS:
|
||||
- OrderedSet (~> 5.0)
|
||||
- flutter_local_notifications (0.0.1):
|
||||
- Flutter
|
||||
- flutter_secure_storage (3.3.1):
|
||||
- flutter_secure_storage (6.0.0):
|
||||
- Flutter
|
||||
- flutter_siri_suggestions (0.0.1):
|
||||
- Flutter
|
||||
@ -24,15 +26,17 @@ PODS:
|
||||
- OrderedSet (5.0.0)
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- path_provider_ios (0.0.1):
|
||||
- path_provider_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- ReachabilitySwift (5.0.0)
|
||||
- receive_sharing_intent (0.0.1):
|
||||
- Flutter
|
||||
- share_plus (0.0.1):
|
||||
- Flutter
|
||||
- shared_preferences_ios (0.0.1):
|
||||
- shared_preferences_foundation (0.0.1):
|
||||
- Flutter
|
||||
- FlutterMacOS
|
||||
- sqflite (0.0.2):
|
||||
- Flutter
|
||||
- FMDB (>= 2.7.5)
|
||||
@ -50,16 +54,17 @@ PODS:
|
||||
DEPENDENCIES:
|
||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||
- Flutter (from `Flutter`)
|
||||
- flutter_email_sender (from `.symlinks/plugins/flutter_email_sender/ios`)
|
||||
- flutter_inappwebview (from `.symlinks/plugins/flutter_inappwebview/ios`)
|
||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
||||
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||
- flutter_siri_suggestions (from `.symlinks/plugins/flutter_siri_suggestions/ios`)
|
||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/ios`)
|
||||
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
|
||||
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||
- shared_preferences_ios (from `.symlinks/plugins/shared_preferences_ios/ios`)
|
||||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/ios`)
|
||||
- sqflite (from `.symlinks/plugins/sqflite/ios`)
|
||||
- synced_shared_preferences (from `.symlinks/plugins/synced_shared_preferences/ios`)
|
||||
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||
@ -78,6 +83,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||
Flutter:
|
||||
:path: Flutter
|
||||
flutter_email_sender:
|
||||
:path: ".symlinks/plugins/flutter_email_sender/ios"
|
||||
flutter_inappwebview:
|
||||
:path: ".symlinks/plugins/flutter_inappwebview/ios"
|
||||
flutter_local_notifications:
|
||||
@ -90,14 +97,14 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/integration_test/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_ios:
|
||||
:path: ".symlinks/plugins/path_provider_ios/ios"
|
||||
path_provider_foundation:
|
||||
:path: ".symlinks/plugins/path_provider_foundation/ios"
|
||||
receive_sharing_intent:
|
||||
:path: ".symlinks/plugins/receive_sharing_intent/ios"
|
||||
share_plus:
|
||||
:path: ".symlinks/plugins/share_plus/ios"
|
||||
shared_preferences_ios:
|
||||
:path: ".symlinks/plugins/shared_preferences_ios/ios"
|
||||
shared_preferences_foundation:
|
||||
:path: ".symlinks/plugins/shared_preferences_foundation/ios"
|
||||
sqflite:
|
||||
:path: ".symlinks/plugins/sqflite/ios"
|
||||
synced_shared_preferences:
|
||||
@ -114,22 +121,23 @@ EXTERNAL SOURCES:
|
||||
SPEC CHECKSUMS:
|
||||
connectivity_plus: 413a8857dd5d9f1c399a39130850d02fe0feaf7e
|
||||
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
|
||||
flutter_email_sender: 02d7443217d8c41483223627972bfdc09f74276b
|
||||
flutter_inappwebview: bfd58618f49dc62f2676de690fc6dcda1d6c3721
|
||||
flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743
|
||||
flutter_secure_storage: 7953c38a04c3fdbb00571bcd87d8e3b5ceb9daec
|
||||
flutter_secure_storage: 23fc622d89d073675f2eaa109381aefbcf5a49be
|
||||
flutter_siri_suggestions: 226fb7ef33d25d3fe0d4aa2a8bcf4b72730c466f
|
||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
||||
integration_test: a1e7d09bd98eca2fc37aefd79d4f41ad37bdbbe5
|
||||
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
|
||||
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
|
||||
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
|
||||
path_provider_foundation: 37748e03f12783f9de2cb2c4eadfaa25fe6d4852
|
||||
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
|
||||
receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1
|
||||
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68
|
||||
shared_preferences_ios: 548a61f8053b9b8a49ac19c1ffbc8b92c50d68ad
|
||||
shared_preferences_foundation: 297b3ebca31b34ec92be11acd7fb0ba932c822ca
|
||||
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
|
||||
synced_shared_preferences: f722742b06d65c7315b8e9f56b794c9fbd5597f7
|
||||
url_launcher_ios: 839c58cdb4279282219f5e248c3321761ff3c4de
|
||||
url_launcher_ios: ae1517e5e344f5544fb090b079e11f399dfbe4d2
|
||||
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
|
||||
webview_flutter_wkwebview: b7e70ef1ddded7e69c796c7390ee74180182971f
|
||||
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6
|
||||
|
@ -3,7 +3,7 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 51;
|
||||
objectVersion = 54;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
@ -360,6 +360,7 @@
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
@ -413,6 +414,7 @@
|
||||
};
|
||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
|
@ -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")!)
|
||||
}
|
||||
|
||||
|
@ -74,5 +74,7 @@
|
||||
</array>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
@ -37,6 +37,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
on<StoryRead>(onStoryRead);
|
||||
on<StoriesLoaded>(onStoriesLoaded);
|
||||
on<StoriesDownload>(onDownload);
|
||||
on<StoriesCancelDownload>(onCancelDownload);
|
||||
on<StoryDownloaded>(onStoryDownloaded);
|
||||
on<StoriesExitOffline>(onExitOffline);
|
||||
on<StoriesPageSizeChanged>(onPageSizeChanged);
|
||||
@ -70,7 +71,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
) async {
|
||||
_streamSubscription ??=
|
||||
_preferenceCubit.stream.listen((PreferenceState event) {
|
||||
final bool isComplexTile = event.showComplexStoryTile;
|
||||
final bool isComplexTile = event.complexStoryTileEnabled;
|
||||
final int pageSize = _getPageSize(isComplexTile: isComplexTile);
|
||||
|
||||
if (pageSize != state.currentPageSize) {
|
||||
@ -78,12 +79,17 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
}
|
||||
});
|
||||
final bool hasCachedStories = await _offlineRepository.hasCachedStories;
|
||||
final bool isComplexTile = _preferenceCubit.state.showComplexStoryTile;
|
||||
final bool isComplexTile = _preferenceCubit.state.complexStoryTileEnabled;
|
||||
final int pageSize = _getPageSize(isComplexTile: isComplexTile);
|
||||
emit(
|
||||
const StoriesState.init().copyWith(
|
||||
offlineReading: hasCachedStories,
|
||||
offlineReading: hasCachedStories &&
|
||||
// Only go into offline mode in the next session.
|
||||
state.downloadStatus == StoriesDownloadStatus.initial,
|
||||
currentPageSize: pageSize,
|
||||
downloadStatus: state.downloadStatus,
|
||||
storiesDownloaded: state.storiesDownloaded,
|
||||
storiesToBeDownloaded: state.storiesToBeDownloaded,
|
||||
),
|
||||
);
|
||||
for (final StoryType type in types) {
|
||||
@ -296,12 +302,30 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onCancelDownload(
|
||||
StoriesCancelDownload event,
|
||||
Emitter<StoriesState> emit,
|
||||
) async {
|
||||
emit(
|
||||
state.copyWith(
|
||||
downloadStatus: StoriesDownloadStatus.canceled,
|
||||
),
|
||||
);
|
||||
|
||||
await _offlineRepository.deleteAllStoryIds();
|
||||
await _offlineRepository.deleteAllStories();
|
||||
await _offlineRepository.deleteAllComments();
|
||||
}
|
||||
|
||||
Future<void> fetchAndCacheStories(
|
||||
Iterable<int> ids, {
|
||||
required bool includingWebPage,
|
||||
required bool isPrioritized,
|
||||
}) async {
|
||||
for (final int id in ids) {
|
||||
if (state.downloadStatus == StoriesDownloadStatus.canceled) break;
|
||||
|
||||
_logger.d('fetching story $id');
|
||||
final Story? story = await _storiesRepository.fetchStoryBy(id);
|
||||
|
||||
if (story == null) {
|
||||
@ -329,11 +353,13 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
.fetchAllChildrenComments(ids: story.kids)
|
||||
.whereType<Comment>()
|
||||
.listen(
|
||||
(Comment comment) => unawaited(
|
||||
_offlineRepository.cacheComment(comment: comment),
|
||||
),
|
||||
)
|
||||
.onDone(() => add(StoryDownloaded(skipped: false)));
|
||||
(Comment comment) {
|
||||
_logger.d('fetched comment ${comment.id}');
|
||||
unawaited(
|
||||
_offlineRepository.cacheComment(comment: comment),
|
||||
);
|
||||
},
|
||||
).onDone(() => add(StoryDownloaded(skipped: false)));
|
||||
}
|
||||
}
|
||||
|
||||
@ -374,7 +400,6 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
|
||||
StoriesPageSizeChanged event,
|
||||
Emitter<StoriesState> emit,
|
||||
) async {
|
||||
emit(const StoriesState.init());
|
||||
add(StoriesInitialize());
|
||||
}
|
||||
|
||||
|
@ -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});
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
41
lib/config/file_output.dart
Normal file
41
lib/config/file_output.dart
Normal 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();
|
||||
}
|
||||
}
|
@ -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())
|
||||
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -42,9 +42,9 @@ class FavState extends Equatable {
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
favIds,
|
||||
favItems,
|
||||
status,
|
||||
currentPage,
|
||||
favIds,
|
||||
favItems,
|
||||
];
|
||||
}
|
||||
|
@ -42,9 +42,9 @@ class HistoryState extends Equatable {
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
submittedIds,
|
||||
submittedItems,
|
||||
status,
|
||||
currentPage,
|
||||
submittedIds,
|
||||
submittedItems,
|
||||
];
|
||||
}
|
||||
|
@ -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,
|
||||
|
@ -77,11 +77,11 @@ class NotificationState extends Equatable {
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[
|
||||
comments,
|
||||
unreadCommentsIds,
|
||||
allCommentsIds,
|
||||
currentPage,
|
||||
offset,
|
||||
status,
|
||||
comments,
|
||||
unreadCommentsIds,
|
||||
allCommentsIds,
|
||||
];
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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);
|
||||
|
46
lib/cubits/tab/tab_cubit.dart
Normal file
46
lib/cubits/tab/tab_cubit.dart
Normal 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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
18
lib/cubits/tab/tab_state.dart
Normal file
18
lib/cubits/tab/tab_state.dart
Normal 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];
|
||||
}
|
@ -22,12 +22,12 @@ extension ContextExtension on BuildContext {
|
||||
static double _screenWidth = 0;
|
||||
static double _storyTileHeight = 0;
|
||||
static int _storyTileMaxLines = 4;
|
||||
static const double _screenWidthLowerBound = 430,
|
||||
_screenWidthUpperBound = 850,
|
||||
_picHeightLowerBound = 110,
|
||||
_picHeightUpperBound = 128,
|
||||
_smallPicHeight = 100,
|
||||
_picHeightFactor = 0.3;
|
||||
static const double _screenWidthLowerBound = 430;
|
||||
static const double _screenWidthUpperBound = 850;
|
||||
static const double _picHeightLowerBound = 110;
|
||||
static const double _picHeightUpperBound = 128;
|
||||
static const double _smallPicHeight = 100;
|
||||
static const double _picHeightFactor = 0.3;
|
||||
|
||||
double get storyTileHeight {
|
||||
final double screenWidth =
|
||||
|
@ -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()}');
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hacki/blocs/auth/auth_bloc.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/main.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/screens/item/models/models.dart';
|
||||
import 'package:hacki/screens/item/widgets/widgets.dart';
|
||||
import 'package:hacki/screens/screens.dart' show ItemScreen, ItemScreenArgs;
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
extension StateExtension on State {
|
||||
void showSnackBar({
|
||||
@ -19,7 +29,7 @@ extension StateExtension on State {
|
||||
? SnackBarAction(
|
||||
label: label,
|
||||
onPressed: action,
|
||||
textColor: Theme.of(context).textTheme.bodyText1?.color,
|
||||
textColor: Theme.of(context).textTheme.bodyLarge?.color,
|
||||
)
|
||||
: null,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
@ -44,4 +54,203 @@ extension StateExtension on State {
|
||||
|
||||
return Future<void>.value();
|
||||
}
|
||||
|
||||
void onMoreTapped(Item item, Rect? rect) {
|
||||
HapticFeedback.lightImpact();
|
||||
|
||||
if (item.dead || item.deleted) {
|
||||
return;
|
||||
}
|
||||
|
||||
final bool isBlocked =
|
||||
context.read<BlocklistCubit>().state.blocklist.contains(item.by);
|
||||
showModalBottomSheet<MenuAction>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return MorePopupMenu(
|
||||
item: item,
|
||||
isBlocked: isBlocked,
|
||||
showSnackBar: showSnackBar,
|
||||
onStoryLinkTapped: onStoryLinkTapped,
|
||||
onLoginTapped: onLoginTapped,
|
||||
);
|
||||
},
|
||||
).then((MenuAction? action) {
|
||||
if (action != null) {
|
||||
switch (action) {
|
||||
case MenuAction.upvote:
|
||||
break;
|
||||
case MenuAction.downvote:
|
||||
break;
|
||||
case MenuAction.share:
|
||||
onShareTapped(item, rect);
|
||||
break;
|
||||
case MenuAction.flag:
|
||||
onFlagTapped(item);
|
||||
break;
|
||||
case MenuAction.block:
|
||||
onBlockTapped(item, isBlocked: isBlocked);
|
||||
break;
|
||||
case MenuAction.cancel:
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> onStoryLinkTapped(String link) async {
|
||||
final int? id = link.itemId;
|
||||
if (id != null) {
|
||||
await locator
|
||||
.get<StoriesRepository>()
|
||||
.fetchItemBy(id: id)
|
||||
.then((Item? item) {
|
||||
if (mounted) {
|
||||
if (item != null) {
|
||||
HackiApp.navigatorKey.currentState!.pushNamed(
|
||||
ItemScreen.routeName,
|
||||
arguments: ItemScreenArgs(item: item),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
LinkUtil.launch(link);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onShareTapped(Item item, Rect? rect) async {
|
||||
late final String? linkToShare;
|
||||
if (item.url.isNotEmpty) {
|
||||
linkToShare = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return Container(
|
||||
height: 140,
|
||||
color: Theme.of(context).canvasColor,
|
||||
child: Material(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
onTap: () => Navigator.pop(context, item.url),
|
||||
title: const Text('Link to article'),
|
||||
),
|
||||
ListTile(
|
||||
onTap: () => Navigator.pop(
|
||||
context,
|
||||
'https://news.ycombinator.com/item?id=${item.id}',
|
||||
),
|
||||
title: const Text('Link to HN'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
linkToShare = 'https://news.ycombinator.com/item?id=${item.id}';
|
||||
}
|
||||
|
||||
if (linkToShare != null) {
|
||||
await Share.share(
|
||||
linkToShare,
|
||||
sharePositionOrigin: rect,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void onFlagTapped(Item item) {
|
||||
showDialog<bool>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Flag this comment?'),
|
||||
content: Text(
|
||||
'Flag this comment posted by ${item.by}?',
|
||||
style: const TextStyle(
|
||||
color: Palette.grey,
|
||||
),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text(
|
||||
'Cancel',
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text(
|
||||
'Yes',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
).then((bool? yesTapped) {
|
||||
if (yesTapped ?? false) {
|
||||
context.read<AuthBloc>().add(AuthFlag(item: item));
|
||||
showSnackBar(content: 'Comment flagged!');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void onBlockTapped(Item item, {required bool isBlocked}) {
|
||||
showDialog<bool>(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text('${isBlocked ? 'Unblock' : 'Block'} this user?'),
|
||||
content: Text(
|
||||
'Do you want to ${isBlocked ? 'unblock' : 'block'} ${item.by}'
|
||||
' and ${isBlocked ? 'display' : 'hide'} '
|
||||
'comments posted by this user?',
|
||||
style: const TextStyle(
|
||||
color: Palette.grey,
|
||||
),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text(
|
||||
'Cancel',
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text(
|
||||
'Yes',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
).then((bool? yesTapped) {
|
||||
if (yesTapped ?? false) {
|
||||
if (isBlocked) {
|
||||
context.read<BlocklistCubit>().removeFromBlocklist(item.by);
|
||||
} else {
|
||||
context.read<BlocklistCubit>().addToBlocklist(item.by);
|
||||
}
|
||||
showSnackBar(content: 'User ${isBlocked ? 'unblocked' : 'blocked'}!');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void onLoginTapped() {
|
||||
final TextEditingController usernameController = TextEditingController();
|
||||
final TextEditingController passwordController = TextEditingController();
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext context) {
|
||||
return LoginDialog(
|
||||
usernameController: usernameController,
|
||||
passwordController: passwordController,
|
||||
showSnackBar: showSnackBar,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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?>[
|
||||
|
@ -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;
|
||||
|
@ -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';
|
||||
|
@ -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;
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
import 'dart:collection';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:equatable/equatable.dart';
|
||||
@ -13,22 +14,29 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
|
||||
|
||||
Preference<T> copyWith({required T? val});
|
||||
|
||||
static List<Preference<dynamic>> allPreferences = <Preference<dynamic>>[
|
||||
FetchModePreference(),
|
||||
CommentsOrderPreference(),
|
||||
FontSizePreference(),
|
||||
// order here reflects the order on settings screen.
|
||||
const DisplayModePreference(),
|
||||
const MetadataModePreference(),
|
||||
const StoryUrlModePreference(),
|
||||
const NotificationModePreference(),
|
||||
const CollapseModePreference(),
|
||||
NavigationModePreference(),
|
||||
const ReaderModePreference(),
|
||||
const MarkReadStoriesModePreference(),
|
||||
const EyeCandyModePreference(),
|
||||
const TrueDarkModePreference(),
|
||||
];
|
||||
static final List<Preference<dynamic>> allPreferences =
|
||||
UnmodifiableListView<Preference<dynamic>>(
|
||||
<Preference<dynamic>>[
|
||||
// Order of these first four preferences does not matter.
|
||||
FetchModePreference(),
|
||||
CommentsOrderPreference(),
|
||||
FontSizePreference(),
|
||||
TabOrderPreference(),
|
||||
// Order of items below matters and
|
||||
// reflects the order on settings screen.
|
||||
const DisplayModePreference(),
|
||||
const MetadataModePreference(),
|
||||
const StoryUrlModePreference(),
|
||||
const NotificationModePreference(),
|
||||
const SwipeGesturePreference(),
|
||||
const CollapseModePreference(),
|
||||
NavigationModePreference(),
|
||||
const ReaderModePreference(),
|
||||
const MarkReadStoriesModePreference(),
|
||||
const EyeCandyModePreference(),
|
||||
const TrueDarkModePreference(),
|
||||
],
|
||||
);
|
||||
|
||||
@override
|
||||
List<Object?> get props => <Object?>[key];
|
||||
@ -43,8 +51,9 @@ abstract class IntPreference extends Preference<int> {
|
||||
}
|
||||
|
||||
const bool _notificationModeDefaultValue = true;
|
||||
const bool _swipeGestureModeDefaultValue = false;
|
||||
const bool _displayModeDefaultValue = true;
|
||||
const bool _navigationModeDefaultValueIOS = true;
|
||||
const bool _navigationModeDefaultValueIOS = false;
|
||||
const bool _navigationModeDefaultValueAndroid = false;
|
||||
const bool _eyeCandyModeDefaultValue = false;
|
||||
const bool _trueDarkModeDefaultValue = false;
|
||||
@ -52,10 +61,32 @@ const bool _readerModeDefaultValue = true;
|
||||
const bool _markReadStoriesModeDefaultValue = true;
|
||||
const bool _metadataModeDefaultValue = true;
|
||||
const bool _storyUrlModeDefaultValue = true;
|
||||
const bool _collapseModeDefaultValue = false;
|
||||
const bool _collapseModeDefaultValue = true;
|
||||
final int _fetchModeDefaultValue = FetchMode.eager.index;
|
||||
final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
|
||||
final int _fontSizeDefaultValue = FontSize.regular.index;
|
||||
final int _tabOrderDefaultValue =
|
||||
StoryType.convertToSettingsValue(StoryType.values);
|
||||
|
||||
class SwipeGesturePreference extends BooleanPreference {
|
||||
const SwipeGesturePreference({bool? val})
|
||||
: super(val: val ?? _swipeGestureModeDefaultValue);
|
||||
|
||||
@override
|
||||
SwipeGesturePreference copyWith({required bool? val}) {
|
||||
return SwipeGesturePreference(val: val);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'swipeGestureMode';
|
||||
|
||||
@override
|
||||
String get title => 'Enable Swipe Gesture';
|
||||
|
||||
@override
|
||||
String get subtitle =>
|
||||
'''enable swipe gesture for switching between tabs. If enabled, long press on Story tile to trigger the action menu.''';
|
||||
}
|
||||
|
||||
class NotificationModePreference extends BooleanPreference {
|
||||
const NotificationModePreference({bool? val})
|
||||
@ -91,6 +122,10 @@ class CollapseModePreference extends BooleanPreference {
|
||||
|
||||
@override
|
||||
String get title => 'Tap Anywhere to Collapse';
|
||||
|
||||
@override
|
||||
String get subtitle =>
|
||||
'''if disabled, tap on the top of comment tile to collapse.''';
|
||||
}
|
||||
|
||||
/// The value deciding whether or not the story
|
||||
@ -304,3 +339,18 @@ class FontSizePreference extends IntPreference {
|
||||
@override
|
||||
String get title => 'Default font size';
|
||||
}
|
||||
|
||||
class TabOrderPreference extends IntPreference {
|
||||
TabOrderPreference({int? val}) : super(val: val ?? _tabOrderDefaultValue);
|
||||
|
||||
@override
|
||||
TabOrderPreference copyWith({required int? val}) {
|
||||
return TabOrderPreference(val: val);
|
||||
}
|
||||
|
||||
@override
|
||||
String get key => 'tabOrder';
|
||||
|
||||
@override
|
||||
String get title => 'Tab order';
|
||||
}
|
||||
|
@ -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' : ''}''';
|
||||
|
34
lib/models/story_type.dart
Normal file
34
lib/models/story_type.dart
Normal 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'),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/postable_repository.dart';
|
||||
@ -9,13 +8,12 @@ import 'package:logger/logger.dart';
|
||||
|
||||
class AuthRepository extends PostableRepository {
|
||||
AuthRepository({
|
||||
Dio? dio,
|
||||
super.dio,
|
||||
PreferenceRepository? preferenceRepository,
|
||||
Logger? logger,
|
||||
}) : _preferenceRepository =
|
||||
preferenceRepository ?? locator.get<PreferenceRepository>(),
|
||||
_logger = logger ?? locator.get<Logger>(),
|
||||
super(dio: dio);
|
||||
_logger = logger ?? locator.get<Logger>();
|
||||
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
final Logger _logger;
|
||||
|
@ -8,10 +8,9 @@ import 'package:hacki/repositories/preference_repository.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
|
||||
class PostRepository extends PostableRepository {
|
||||
PostRepository({Dio? dio, PreferenceRepository? storageRepository})
|
||||
PostRepository({super.dio, PreferenceRepository? storageRepository})
|
||||
: _preferenceRepository =
|
||||
storageRepository ?? locator.get<PreferenceRepository>(),
|
||||
super(dio: dio);
|
||||
storageRepository ?? locator.get<PreferenceRepository>();
|
||||
|
||||
final PreferenceRepository _preferenceRepository;
|
||||
|
||||
|
337
lib/screens/home/home_screen.dart
Normal file
337
lib/screens/home/home_screen.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
31
lib/screens/home/widgets/mobile_home_screen.dart
Normal file
31
lib/screens/home/widgets/mobile_home_screen.dart
Normal 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(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
72
lib/screens/home/widgets/pinned_stories.dart
Normal file
72
lib/screens/home/widgets/pinned_stories.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
92
lib/screens/home/widgets/tablet_home_screen.dart
Normal file
92
lib/screens/home/widgets/tablet_home_screen.dart
Normal 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.'),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
3
lib/screens/home/widgets/widgets.dart
Normal file
3
lib/screens/home/widgets/widgets.dart
Normal file
@ -0,0 +1,3 @@
|
||||
export 'mobile_home_screen.dart';
|
||||
export 'pinned_stories.dart';
|
||||
export 'tablet_home_screen.dart';
|
@ -1,618 +0,0 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:badges/badges.dart';
|
||||
import 'package:feature_discovery/feature_discovery.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_fadein/flutter_fadein.dart';
|
||||
import 'package:flutter_siri_suggestions/flutter_siri_suggestions.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/config/locator.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/main.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/repositories/repositories.dart';
|
||||
import 'package:hacki/screens/screens.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/services/services.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:receive_sharing_intent/receive_sharing_intent.dart';
|
||||
import 'package:responsive_builder/responsive_builder.dart';
|
||||
|
||||
class HomeScreen extends StatefulWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
static const String routeName = '/';
|
||||
|
||||
static Route<dynamic> route() {
|
||||
return MaterialPageRoute<HomeScreen>(
|
||||
settings: const RouteSettings(name: routeName),
|
||||
builder: (BuildContext context) => const HomeScreen(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
_HomeScreenState createState() => _HomeScreenState();
|
||||
}
|
||||
|
||||
class _HomeScreenState extends State<HomeScreen>
|
||||
with SingleTickerProviderStateMixin, RouteAware {
|
||||
final Throttle featureDiscoveryDismissThrottle = Throttle(
|
||||
delay: _throttleDelay,
|
||||
);
|
||||
|
||||
late final TabController tabController;
|
||||
late final StreamSubscription<String> intentDataStreamSubscription;
|
||||
late final StreamSubscription<String?> notificationStreamSubscription;
|
||||
late final StreamSubscription<String?> siriSuggestionStreamSubscription;
|
||||
|
||||
int currentIndex = 0;
|
||||
|
||||
static const Duration _throttleDelay = Duration(seconds: 1);
|
||||
|
||||
@override
|
||||
void didPopNext() {
|
||||
super.didPopNext();
|
||||
if (context.read<StoriesBloc>().deviceScreenType ==
|
||||
DeviceScreenType.mobile) {
|
||||
locator.get<Logger>().i('resetting comments in CommentCache');
|
||||
Future<void>.delayed(
|
||||
const Duration(milliseconds: 500),
|
||||
locator.get<CommentCache>().resetComments,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// This is for testing only.
|
||||
// FeatureDiscovery.clearPreferences(context, <String>[
|
||||
// Constants.featureLogIn,
|
||||
// Constants.featureAddStoryToFavList,
|
||||
// Constants.featureOpenStoryInWebView,
|
||||
// Constants.featurePinToTop,
|
||||
// ]);
|
||||
|
||||
ReceiveSharingIntent.getInitialText().then(onShareExtensionTapped);
|
||||
|
||||
intentDataStreamSubscription =
|
||||
ReceiveSharingIntent.getTextStream().listen(onShareExtensionTapped);
|
||||
|
||||
if (!selectNotificationSubject.hasListener) {
|
||||
notificationStreamSubscription =
|
||||
selectNotificationSubject.stream.listen(onNotificationTapped);
|
||||
}
|
||||
|
||||
if (!siriSuggestionSubject.hasListener) {
|
||||
siriSuggestionStreamSubscription =
|
||||
siriSuggestionSubject.stream.listen(onSiriSuggestionTapped);
|
||||
}
|
||||
|
||||
SchedulerBinding.instance
|
||||
..addPostFrameCallback((_) {
|
||||
if (!isTesting) {
|
||||
FeatureDiscovery.discoverFeatures(
|
||||
context,
|
||||
const <String>{
|
||||
Constants.featureLogIn,
|
||||
},
|
||||
);
|
||||
}
|
||||
})
|
||||
..addPostFrameCallback((_) {
|
||||
final ModalRoute<dynamic>? route = ModalRoute.of(context);
|
||||
|
||||
if (route == null) return;
|
||||
|
||||
locator
|
||||
.get<RouteObserver<ModalRoute<dynamic>>>()
|
||||
.subscribe(this, route);
|
||||
});
|
||||
|
||||
tabController = TabController(vsync: this, length: 6)
|
||||
..addListener(() {
|
||||
setState(() {
|
||||
currentIndex = tabController.index;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final DeviceScreenType deviceType =
|
||||
getDeviceType(MediaQuery.of(context).size);
|
||||
if (context.read<StoriesBloc>().deviceScreenType != deviceType) {
|
||||
context.read<StoriesBloc>().deviceScreenType = deviceType;
|
||||
context.read<StoriesBloc>().add(StoriesInitialize());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
featureDiscoveryDismissThrottle.dispose();
|
||||
tabController.dispose();
|
||||
intentDataStreamSubscription.cancel();
|
||||
notificationStreamSubscription.cancel();
|
||||
siriSuggestionStreamSubscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final BlocBuilder<PreferenceCubit, PreferenceState> homeScreen =
|
||||
BlocBuilder<PreferenceCubit, PreferenceState>(
|
||||
buildWhen: (PreferenceState previous, PreferenceState current) =>
|
||||
previous.showComplexStoryTile != current.showComplexStoryTile ||
|
||||
previous.showMetadata != current.showMetadata,
|
||||
builder: (BuildContext context, PreferenceState preferenceState) {
|
||||
final BlocBuilder<PinCubit, PinState> pinnedStories =
|
||||
BlocBuilder<PinCubit, PinState>(
|
||||
builder: (BuildContext context, PinState state) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
for (final Story story in state.pinnedStories)
|
||||
FadeIn(
|
||||
child: Slidable(
|
||||
startActionPane: ActionPane(
|
||||
motion: const BehindMotion(),
|
||||
children: <Widget>[
|
||||
SlidableAction(
|
||||
onPressed: (_) {
|
||||
HapticFeedback.lightImpact();
|
||||
context.read<PinCubit>().unpinStory(story);
|
||||
},
|
||||
backgroundColor: Palette.red,
|
||||
foregroundColor: Palette.white,
|
||||
icon: preferenceState.showComplexStoryTile
|
||||
? Icons.close
|
||||
: null,
|
||||
label: 'Unpin',
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Container(
|
||||
color: Palette.orangeAccent.withOpacity(0.2),
|
||||
child: StoryTile(
|
||||
key: ValueKey<String>('${story.id}-PinnedStoryTile'),
|
||||
story: story,
|
||||
onTap: () => onStoryTapped(story, isPin: true),
|
||||
showWebPreview: preferenceState.showComplexStoryTile,
|
||||
showMetadata: preferenceState.showMetadata,
|
||||
showUrl: preferenceState.showUrl,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (state.pinnedStories.isNotEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: Dimens.pt12),
|
||||
child: Divider(
|
||||
color: Palette.orangeAccent,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return DefaultTabController(
|
||||
length: 6,
|
||||
child: Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: PreferredSize(
|
||||
preferredSize: const Size(
|
||||
Dimens.zero,
|
||||
Dimens.pt40,
|
||||
),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
SizedBox(
|
||||
height: MediaQuery.of(context).padding.top - Dimens.pt8,
|
||||
),
|
||||
Theme(
|
||||
data: ThemeData(
|
||||
highlightColor: Palette.transparent,
|
||||
splashColor: Palette.transparent,
|
||||
primaryColor: Theme.of(context).primaryColor,
|
||||
),
|
||||
child: TabBar(
|
||||
isScrollable: true,
|
||||
controller: tabController,
|
||||
indicatorColor: Palette.orange,
|
||||
indicator: CircleTabIndicator(
|
||||
color: Palette.orange,
|
||||
radius: Dimens.pt2,
|
||||
),
|
||||
indicatorPadding: const EdgeInsets.only(
|
||||
bottom: Dimens.pt8,
|
||||
),
|
||||
onTap: (_) {
|
||||
HapticFeedback.selectionClick();
|
||||
},
|
||||
tabs: <Widget>[
|
||||
for (int i = 0; i < StoriesBloc.types.length; i++)
|
||||
Tab(
|
||||
key: ValueKey<StoryType>(
|
||||
StoriesBloc.types.elementAt(i),
|
||||
),
|
||||
child: Text(
|
||||
StoriesBloc.types.elementAt(i).label,
|
||||
style: TextStyle(
|
||||
fontSize: currentIndex == i
|
||||
? TextDimens.pt14
|
||||
: TextDimens.pt10,
|
||||
color: currentIndex == i
|
||||
? Palette.orange
|
||||
: Palette.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
Tab(
|
||||
child: DescribedFeatureOverlay(
|
||||
onBackgroundTap: onFeatureDiscoveryDismissed,
|
||||
onDismiss: onFeatureDiscoveryDismissed,
|
||||
onComplete: () async {
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
showOnboarding();
|
||||
return true;
|
||||
},
|
||||
overflowMode: OverflowMode.extendBackground,
|
||||
targetColor: Theme.of(context).primaryColor,
|
||||
tapTarget: const Icon(
|
||||
Icons.person,
|
||||
size: TextDimens.pt16,
|
||||
color: Palette.white,
|
||||
),
|
||||
featureId: Constants.featureLogIn,
|
||||
title: const Text('Log in for more'),
|
||||
description: const Text(
|
||||
'Log in using your Hacker News account '
|
||||
'to check out stories and comments you have '
|
||||
'posted in the past, and get in-app '
|
||||
'notification when there is new reply to '
|
||||
'your comments or stories.',
|
||||
style: TextStyle(fontSize: TextDimens.pt16),
|
||||
),
|
||||
child: BlocBuilder<NotificationCubit,
|
||||
NotificationState>(
|
||||
buildWhen: (
|
||||
NotificationState previous,
|
||||
NotificationState current,
|
||||
) =>
|
||||
previous.unreadCommentsIds.length !=
|
||||
current.unreadCommentsIds.length,
|
||||
builder: (
|
||||
BuildContext context,
|
||||
NotificationState state,
|
||||
) {
|
||||
return Badge(
|
||||
showBadge: state.unreadCommentsIds.isNotEmpty,
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
badgeContent: Container(
|
||||
height: Dimens.pt3,
|
||||
width: Dimens.pt3,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Palette.white,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.person,
|
||||
size: currentIndex == 5
|
||||
? TextDimens.pt16
|
||||
: TextDimens.pt12,
|
||||
color: currentIndex == 5
|
||||
? Palette.orange
|
||||
: Palette.grey,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: TabBarView(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
controller: tabController,
|
||||
children: <Widget>[
|
||||
for (final StoryType type in StoriesBloc.types)
|
||||
StoriesListView(
|
||||
key: ValueKey<StoryType>(type),
|
||||
storyType: type,
|
||||
header: pinnedStories,
|
||||
onStoryTapped: onStoryTapped,
|
||||
),
|
||||
const ProfileScreen(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
return ScreenTypeLayout.builder(
|
||||
mobile: (BuildContext context) {
|
||||
context.read<SplitViewCubit>().disableSplitView();
|
||||
return _MobileHomeScreen(
|
||||
homeScreen: homeScreen,
|
||||
);
|
||||
},
|
||||
tablet: (BuildContext context) => _TabletHomeScreen(
|
||||
homeScreen: homeScreen,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> onFeatureDiscoveryDismissed() {
|
||||
featureDiscoveryDismissThrottle.run(() {
|
||||
HapticFeedback.lightImpact();
|
||||
ScaffoldMessenger.of(context).clearSnackBars();
|
||||
showSnackBar(content: 'Tap on icon to continue');
|
||||
});
|
||||
|
||||
return Future<bool>.value(false);
|
||||
}
|
||||
|
||||
void onStoryTapped(Story story, {bool isPin = false}) {
|
||||
final bool showWebFirst =
|
||||
context.read<PreferenceCubit>().state.showWebFirst;
|
||||
final bool useReader = context.read<PreferenceCubit>().state.useReader;
|
||||
final bool offlineReading =
|
||||
context.read<StoriesBloc>().state.offlineReading;
|
||||
final bool hasRead = isPin || context.read<StoriesBloc>().hasRead(story);
|
||||
final bool splitViewEnabled = context.read<SplitViewCubit>().state.enabled;
|
||||
|
||||
// If a story is a job story and it has a link to the job posting,
|
||||
// it would be better to just navigate to the web page.
|
||||
final bool isJobWithLink = story.isJob && story.url.isNotEmpty;
|
||||
|
||||
if (isJobWithLink) {
|
||||
context.read<ReminderCubit>().removeLastReadStoryId();
|
||||
} else {
|
||||
final ItemScreenArgs args = ItemScreenArgs(item: story);
|
||||
|
||||
context.read<ReminderCubit>().updateLastReadStoryId(story.id);
|
||||
|
||||
if (splitViewEnabled) {
|
||||
context.read<SplitViewCubit>().updateItemScreenArgs(args);
|
||||
} else {
|
||||
HackiApp.navigatorKey.currentState
|
||||
?.pushNamed(
|
||||
ItemScreen.routeName,
|
||||
arguments: args,
|
||||
)
|
||||
.whenComplete(() {
|
||||
context.read<ReminderCubit>().removeLastReadStoryId();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (story.url.isNotEmpty && (isJobWithLink || (showWebFirst && !hasRead))) {
|
||||
LinkUtil.launch(
|
||||
story.url,
|
||||
useReader: useReader,
|
||||
offlineReading: offlineReading,
|
||||
);
|
||||
}
|
||||
|
||||
context.read<StoriesBloc>().add(
|
||||
StoryRead(
|
||||
story: story,
|
||||
),
|
||||
);
|
||||
|
||||
if (Platform.isIOS) {
|
||||
FlutterSiriSuggestions.instance.registerActivity(
|
||||
FlutterSiriActivity(
|
||||
story.title,
|
||||
story.id.toString(),
|
||||
suggestedInvocationPhrase: '',
|
||||
contentDescription: story.text,
|
||||
persistentIdentifier: story.id.toString(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void showOnboarding() {
|
||||
Navigator.push<dynamic>(
|
||||
context,
|
||||
MaterialPageRoute<dynamic>(
|
||||
builder: (BuildContext context) => const OnboardingView(),
|
||||
fullscreenDialog: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void onShareExtensionTapped(String? event) {
|
||||
if (event == null) return;
|
||||
|
||||
final int? id = event.itemId;
|
||||
|
||||
if (id != null) {
|
||||
locator.get<StoriesRepository>().fetchItemBy(id: id).then((Item? item) {
|
||||
if (mounted) {
|
||||
if (item != null) {
|
||||
goToItemScreen(
|
||||
args: ItemScreenArgs(item: item),
|
||||
forceNewScreen: true,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onSiriSuggestionTapped(String? id) async {
|
||||
if (id == null) return;
|
||||
final int? storyId = int.tryParse(id);
|
||||
if (storyId == null) return;
|
||||
|
||||
await locator
|
||||
.get<StoriesRepository>()
|
||||
.fetchStoryBy(storyId)
|
||||
.then((Story? story) {
|
||||
if (story == null) {
|
||||
showSnackBar(content: 'Something went wrong...');
|
||||
return;
|
||||
}
|
||||
final ItemScreenArgs args = ItemScreenArgs(item: story);
|
||||
goToItemScreen(args: args);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> onNotificationTapped(String? payload) async {
|
||||
if (payload == null) return;
|
||||
|
||||
final Map<String, dynamic> payloadJson =
|
||||
jsonDecode(payload) as Map<String, dynamic>;
|
||||
|
||||
final int? storyId = payloadJson['storyId'] as int?;
|
||||
final int? commentId = payloadJson['commentId'] as int?;
|
||||
|
||||
if (storyId != null && commentId != null) {
|
||||
context.read<NotificationCubit>().markAsRead(commentId);
|
||||
|
||||
await locator
|
||||
.get<StoriesRepository>()
|
||||
.fetchStoryBy(storyId)
|
||||
.then((Story? story) {
|
||||
if (story == null) {
|
||||
showSnackBar(content: 'Something went wrong...');
|
||||
return;
|
||||
}
|
||||
final ItemScreenArgs args = ItemScreenArgs(item: story);
|
||||
goToItemScreen(args: args);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _MobileHomeScreen extends StatelessWidget {
|
||||
const _MobileHomeScreen({
|
||||
Key? key,
|
||||
required this.homeScreen,
|
||||
}) : super(key: key);
|
||||
|
||||
final Widget homeScreen;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: <Widget>[
|
||||
Positioned.fill(child: homeScreen),
|
||||
if (!context.read<ReminderCubit>().state.hasShown)
|
||||
const Positioned(
|
||||
left: Dimens.pt24,
|
||||
right: Dimens.pt24,
|
||||
bottom: Dimens.pt36,
|
||||
height: Dimens.pt40,
|
||||
child: CountdownReminder(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TabletHomeScreen extends StatelessWidget {
|
||||
const _TabletHomeScreen({
|
||||
Key? key,
|
||||
required this.homeScreen,
|
||||
}) : super(key: key);
|
||||
|
||||
final Widget homeScreen;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ResponsiveBuilder(
|
||||
builder: (BuildContext context, SizingInformation sizeInfo) {
|
||||
context.read<SplitViewCubit>().enableSplitView();
|
||||
double homeScreenWidth = 428;
|
||||
|
||||
if (sizeInfo.screenSize.width < homeScreenWidth * 2) {
|
||||
homeScreenWidth = 345;
|
||||
}
|
||||
|
||||
return BlocBuilder<SplitViewCubit, SplitViewState>(
|
||||
buildWhen: (SplitViewState previous, SplitViewState current) =>
|
||||
previous.expanded != current.expanded,
|
||||
builder: (BuildContext context, SplitViewState state) {
|
||||
return Stack(
|
||||
children: <Widget>[
|
||||
AnimatedPositioned(
|
||||
left: Dimens.zero,
|
||||
top: Dimens.zero,
|
||||
bottom: Dimens.zero,
|
||||
width: state.expanded ? Dimens.zero : homeScreenWidth,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.elasticOut,
|
||||
child: homeScreen,
|
||||
),
|
||||
Positioned(
|
||||
left: Dimens.pt24,
|
||||
bottom: Dimens.pt36,
|
||||
height: Dimens.pt40,
|
||||
width: homeScreenWidth - Dimens.pt24,
|
||||
child: const CountdownReminder(),
|
||||
),
|
||||
AnimatedPositioned(
|
||||
right: Dimens.zero,
|
||||
top: Dimens.zero,
|
||||
bottom: Dimens.zero,
|
||||
left: state.expanded ? Dimens.zero : homeScreenWidth,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.elasticOut,
|
||||
child: const _TabletStoryView(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TabletStoryView extends StatelessWidget {
|
||||
const _TabletStoryView({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<SplitViewCubit, SplitViewState>(
|
||||
buildWhen: (SplitViewState previous, SplitViewState current) =>
|
||||
previous.itemScreenArgs != current.itemScreenArgs,
|
||||
builder: (BuildContext context, SplitViewState state) {
|
||||
if (state.itemScreenArgs != null) {
|
||||
return ItemScreen.build(context, state.itemScreenArgs!);
|
||||
}
|
||||
|
||||
return Material(
|
||||
child: Container(
|
||||
color: Theme.of(context).canvasColor,
|
||||
child: const Center(
|
||||
child: Text('Tap on story tile to view comments.'),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -7,10 +7,10 @@ import 'package:hacki/styles/styles.dart';
|
||||
|
||||
class CustomAppBar extends AppBar {
|
||||
CustomAppBar({
|
||||
Key? key,
|
||||
super.key,
|
||||
required ScrollController scrollController,
|
||||
required Item item,
|
||||
required Color backgroundColor,
|
||||
required Color super.backgroundColor,
|
||||
required Future<bool> Function() onBackgroundTap,
|
||||
required Future<bool> Function() onDismiss,
|
||||
required VoidCallback onFontSizeTap,
|
||||
@ -19,8 +19,6 @@ class CustomAppBar extends AppBar {
|
||||
VoidCallback? onZoomTap,
|
||||
bool? expanded,
|
||||
}) : super(
|
||||
key: key,
|
||||
backgroundColor: backgroundColor,
|
||||
elevation: Dimens.zero,
|
||||
actions: <Widget>[
|
||||
if (splitViewEnabled) ...<Widget>[
|
||||
|
@ -2,22 +2,21 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/config/constants.dart';
|
||||
import 'package:hacki/extensions/extensions.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
|
||||
class LoginDialog extends StatelessWidget {
|
||||
const LoginDialog({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.usernameController,
|
||||
required this.passwordController,
|
||||
required this.showSnackBar,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
final TextEditingController usernameController;
|
||||
final TextEditingController passwordController;
|
||||
final Function({
|
||||
final void Function({
|
||||
required String content,
|
||||
VoidCallback? action,
|
||||
String? label,
|
||||
@ -28,9 +27,10 @@ class LoginDialog extends StatelessWidget {
|
||||
return BlocConsumer<AuthBloc, AuthState>(
|
||||
listener: (BuildContext context, AuthState state) {
|
||||
if (state.isLoggedIn) {
|
||||
final String happyFace = Constants.happyFaces.pickRandomly()!;
|
||||
Navigator.pop(context);
|
||||
showSnackBar(content: 'Logged in successfully! $happyFace');
|
||||
showSnackBar(
|
||||
content: 'Logged in successfully! ${Constants.happyFace}',
|
||||
);
|
||||
}
|
||||
},
|
||||
builder: (BuildContext context, AuthState state) {
|
||||
|
@ -17,7 +17,7 @@ import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||
|
||||
class MainView extends StatelessWidget {
|
||||
const MainView({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.scrollController,
|
||||
required this.refreshController,
|
||||
required this.commentEditingController,
|
||||
@ -29,7 +29,7 @@ class MainView extends StatelessWidget {
|
||||
required this.onStoryLinkTapped,
|
||||
required this.onLoginTapped,
|
||||
required this.onRightMoreTapped,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
final ScrollController scrollController;
|
||||
final RefreshController refreshController;
|
||||
@ -38,11 +38,14 @@ class MainView extends StatelessWidget {
|
||||
final FocusNode focusNode;
|
||||
final double topPadding;
|
||||
final bool splitViewEnabled;
|
||||
final Function(Item item, Rect? rect) onMoreTapped;
|
||||
final void Function(Item item, Rect? rect) onMoreTapped;
|
||||
final ValueChanged<String> onStoryLinkTapped;
|
||||
final VoidCallback onLoginTapped;
|
||||
final ValueChanged<Comment> onRightMoreTapped;
|
||||
|
||||
static const int _loadingIndicatorOpacityAnimationDuration = 300;
|
||||
static const double _trailingBoxHeight = 240;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
@ -130,9 +133,9 @@ class MainView extends StatelessWidget {
|
||||
state.comments.isNotEmpty) ||
|
||||
state.onlyShowTargetComment) {
|
||||
return SizedBox(
|
||||
height: 240,
|
||||
height: _trailingBoxHeight,
|
||||
child: Center(
|
||||
child: Text(Constants.happyFaces.pickRandomly()!),
|
||||
child: Text(Constants.happyFace),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
@ -194,8 +197,13 @@ class MainView extends StatelessWidget {
|
||||
buildWhen: (CommentsState prev, CommentsState current) =>
|
||||
prev.status != current.status,
|
||||
builder: (BuildContext context, CommentsState state) {
|
||||
return Visibility(
|
||||
visible: state.status == CommentsStatus.loading,
|
||||
return AnimatedOpacity(
|
||||
opacity: state.status == CommentsStatus.loading
|
||||
? NumSwitch.on
|
||||
: NumSwitch.off,
|
||||
duration: const Duration(
|
||||
milliseconds: _loadingIndicatorOpacityAnimationDuration,
|
||||
),
|
||||
child: const LinearProgressIndicator(),
|
||||
);
|
||||
},
|
||||
@ -208,7 +216,6 @@ class MainView extends StatelessWidget {
|
||||
|
||||
class _ParentItemSection extends StatelessWidget {
|
||||
const _ParentItemSection({
|
||||
Key? key,
|
||||
required this.scrollController,
|
||||
required this.refreshController,
|
||||
required this.commentEditingController,
|
||||
@ -221,7 +228,7 @@ class _ParentItemSection extends StatelessWidget {
|
||||
required this.onStoryLinkTapped,
|
||||
required this.onLoginTapped,
|
||||
required this.onRightMoreTapped,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
final ScrollController scrollController;
|
||||
final RefreshController refreshController;
|
||||
@ -231,7 +238,7 @@ class _ParentItemSection extends StatelessWidget {
|
||||
final FocusNode focusNode;
|
||||
final double topPadding;
|
||||
final bool splitViewEnabled;
|
||||
final Function(Item item, Rect? rect) onMoreTapped;
|
||||
final void Function(Item item, Rect? rect) onMoreTapped;
|
||||
final ValueChanged<String> onStoryLinkTapped;
|
||||
final VoidCallback onLoginTapped;
|
||||
final ValueChanged<Comment> onRightMoreTapped;
|
||||
@ -317,8 +324,10 @@ class _ParentItemSection extends StatelessWidget {
|
||||
InkWell(
|
||||
onTap: () => LinkUtil.launch(
|
||||
state.item.url,
|
||||
useReader:
|
||||
context.read<PreferenceCubit>().state.useReader,
|
||||
useReader: context
|
||||
.read<PreferenceCubit>()
|
||||
.state
|
||||
.readerEnabled,
|
||||
offlineReading: context
|
||||
.read<StoriesBloc>()
|
||||
.state
|
||||
@ -342,7 +351,7 @@ class _ParentItemSection extends StatelessWidget {
|
||||
prefState.fontSize.fontSize,
|
||||
color: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyText1
|
||||
.bodyLarge
|
||||
?.color,
|
||||
),
|
||||
children: <TextSpan>[
|
||||
|
@ -12,17 +12,17 @@ import 'package:hacki/utils/utils.dart';
|
||||
|
||||
class MorePopupMenu extends StatelessWidget {
|
||||
const MorePopupMenu({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.item,
|
||||
required this.isBlocked,
|
||||
required this.showSnackBar,
|
||||
required this.onStoryLinkTapped,
|
||||
required this.onLoginTapped,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
final Item item;
|
||||
final bool isBlocked;
|
||||
final Function({
|
||||
final void Function({
|
||||
required String content,
|
||||
VoidCallback? action,
|
||||
String? label,
|
||||
|
@ -168,7 +168,7 @@ class PollView extends StatelessWidget {
|
||||
? SnackBarAction(
|
||||
label: label,
|
||||
onPressed: action,
|
||||
textColor: Theme.of(context).textTheme.bodyText1?.color,
|
||||
textColor: Theme.of(context).textTheme.bodyLarge?.color,
|
||||
)
|
||||
: null,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
|
@ -111,7 +111,8 @@ class _ReplyBoxState extends State<ReplyBox> {
|
||||
...<Widget>[
|
||||
if (replyingTo != null)
|
||||
AnimatedOpacity(
|
||||
opacity: expanded ? 1 : 0,
|
||||
opacity:
|
||||
expanded ? NumSwitch.on : NumSwitch.off,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: IconButton(
|
||||
key: const Key('quote'),
|
||||
|
@ -9,19 +9,19 @@ import 'package:responsive_builder/responsive_builder.dart';
|
||||
|
||||
class TimeMachineDialog extends StatelessWidget {
|
||||
const TimeMachineDialog({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.comment,
|
||||
required this.size,
|
||||
required this.deviceType,
|
||||
required this.widthFactor,
|
||||
required this.onStoryLinkTapped,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
final Comment comment;
|
||||
final Size size;
|
||||
final DeviceScreenType deviceType;
|
||||
final double widthFactor;
|
||||
final Function(String) onStoryLinkTapped;
|
||||
final void Function(String) onStoryLinkTapped;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
1
lib/screens/profile/models/models.dart
Normal file
1
lib/screens/profile/models/models.dart
Normal file
@ -0,0 +1 @@
|
||||
export 'page_type.dart';
|
7
lib/screens/profile/models/page_type.dart
Normal file
7
lib/screens/profile/models/page_type.dart
Normal file
@ -0,0 +1,7 @@
|
||||
enum PageType {
|
||||
fav,
|
||||
history,
|
||||
settings,
|
||||
search,
|
||||
notification,
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -22,7 +22,7 @@ class InboxView extends StatelessWidget {
|
||||
final RefreshController refreshController;
|
||||
final List<Comment> comments;
|
||||
final List<int> unreadCommentsIds;
|
||||
final Function(Comment) onCommentTapped;
|
||||
final void Function(Comment) onCommentTapped;
|
||||
final VoidCallback onMarkAllAsReadTapped;
|
||||
final VoidCallback onLoadMore;
|
||||
final VoidCallback onRefresh;
|
||||
|
@ -58,42 +58,67 @@ class OfflineListTile extends StatelessWidget {
|
||||
isThreeLine: true,
|
||||
onTap: () {
|
||||
if (state.downloadStatus == StoriesDownloadStatus.downloading) {
|
||||
return;
|
||||
showDialog<bool>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => AlertDialog(
|
||||
title: const Text('Abort downloading?'),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('No'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Yes'),
|
||||
),
|
||||
],
|
||||
),
|
||||
).then((bool? abortDownloading) {
|
||||
if (abortDownloading ?? false) {
|
||||
Wakelock.enable();
|
||||
context.read<StoriesBloc>().add(StoriesCancelDownload());
|
||||
}
|
||||
});
|
||||
} else {
|
||||
Connectivity().checkConnectivity().then((ConnectivityResult res) {
|
||||
if (res != ConnectivityResult.none) {
|
||||
showDialog<bool>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => AlertDialog(
|
||||
title: const Text('Download web pages as well?'),
|
||||
content: const Text('It will take longer time.'),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('No'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Yes'),
|
||||
),
|
||||
],
|
||||
),
|
||||
).then((bool? includeWebPage) {
|
||||
if (includeWebPage != null) {
|
||||
Wakelock.enable();
|
||||
context.read<StoriesBloc>().add(
|
||||
StoriesDownload(
|
||||
includingWebPage: includeWebPage,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
Connectivity().checkConnectivity().then((ConnectivityResult res) {
|
||||
if (res != ConnectivityResult.none) {
|
||||
showDialog<bool>(
|
||||
context: context,
|
||||
builder: (BuildContext context) => AlertDialog(
|
||||
title: const Text('Download web pages as well?'),
|
||||
content: const Text('It will take longer time.'),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('No'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Yes'),
|
||||
),
|
||||
],
|
||||
),
|
||||
).then((bool? includeWebPage) {
|
||||
if (includeWebPage != null) {
|
||||
Wakelock.enable();
|
||||
context.read<StoriesBloc>().add(
|
||||
StoriesDownload(
|
||||
includingWebPage: includeWebPage,
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
|
561
lib/screens/profile/widgets/settings.dart
Normal file
561
lib/screens/profile/widgets/settings.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
71
lib/screens/profile/widgets/tab_bar_settings.dart
Normal file
71
lib/screens/profile/widgets/tab_bar_settings.dart
Normal 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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -164,23 +164,22 @@ class _SearchScreenState extends State<SearchScreen> {
|
||||
FadeIn(
|
||||
child: StoryTile(
|
||||
showWebPreview:
|
||||
prefState.showComplexStoryTile,
|
||||
showMetadata: prefState.showMetadata,
|
||||
showUrl: prefState.showUrl,
|
||||
prefState.complexStoryTileEnabled,
|
||||
showMetadata: prefState.metadataEnabled,
|
||||
showUrl: prefState.urlEnabled,
|
||||
story: e,
|
||||
onTap: () => goToItemScreen(
|
||||
args: ItemScreenArgs(item: e),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!prefState.showComplexStoryTile)
|
||||
if (!prefState.complexStoryTileEnabled)
|
||||
const Divider(
|
||||
height: Dimens.zero,
|
||||
),
|
||||
],
|
||||
)
|
||||
.expand((List<Widget> e) => e)
|
||||
.toList(),
|
||||
.expand((List<Widget> e) => e),
|
||||
const SizedBox(
|
||||
height: Dimens.pt40,
|
||||
),
|
||||
|
@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
|
||||
typedef DateRangeCallback = Function(DateTime, DateTime);
|
||||
typedef DateRangeCallback = void Function(DateTime, DateTime);
|
||||
|
||||
enum CustomDateTimeRange {
|
||||
pastDay(Duration(days: 1), label: 'past day'),
|
||||
@ -17,10 +17,10 @@ enum CustomDateTimeRange {
|
||||
|
||||
class CustomRangeFilterChip extends StatelessWidget {
|
||||
const CustomRangeFilterChip({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.range,
|
||||
required this.onTap,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
final CustomDateTimeRange range;
|
||||
final DateRangeCallback onTap;
|
||||
|
@ -5,14 +5,14 @@ import 'package:intl/intl.dart';
|
||||
|
||||
class DateTimeRangeFilterChip extends StatelessWidget {
|
||||
const DateTimeRangeFilterChip({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.filter,
|
||||
required this.onDateTimeRangeUpdated,
|
||||
required this.onDateTimeRangeRemoved,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
final DateTimeRangeFilter? filter;
|
||||
final Function(DateTime, DateTime) onDateTimeRangeUpdated;
|
||||
final void Function(DateTime, DateTime) onDateTimeRangeUpdated;
|
||||
final VoidCallback onDateTimeRangeRemoved;
|
||||
|
||||
static final DateFormat _dateTimeFormatter = DateFormat.yMMMd();
|
||||
|
@ -4,9 +4,9 @@ import 'package:hacki/screens/widgets/widgets.dart';
|
||||
|
||||
class PostedByFilterChip extends StatelessWidget {
|
||||
const PostedByFilterChip({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.filter,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
final PostedByFilter? filter;
|
||||
|
||||
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -17,7 +17,7 @@ class BlocBuilder3<
|
||||
BlocC extends StateStreamable<BlocCState>,
|
||||
BlocCState> extends StatelessWidget {
|
||||
const BlocBuilder3({
|
||||
Key? key,
|
||||
super.key,
|
||||
required this.builder,
|
||||
this.blocA,
|
||||
this.blocB,
|
||||
@ -25,7 +25,7 @@ class BlocBuilder3<
|
||||
this.buildWhenA,
|
||||
this.buildWhenB,
|
||||
this.buildWhenC,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
final BlocWidgetBuilder3<BlocAState, BlocBState, BlocCState> builder;
|
||||
|
||||
|
@ -33,11 +33,11 @@ class CommentTile extends StatelessWidget {
|
||||
final Comment comment;
|
||||
final int level;
|
||||
final bool actionable;
|
||||
final Function(Comment)? onReplyTapped;
|
||||
final Function(Comment, Rect?)? onMoreTapped;
|
||||
final Function(Comment)? onEditTapped;
|
||||
final Function(Comment)? onRightMoreTapped;
|
||||
final Function(String) onStoryLinkTapped;
|
||||
final void Function(Comment)? onReplyTapped;
|
||||
final void Function(Comment, Rect?)? onMoreTapped;
|
||||
final void Function(Comment)? onEditTapped;
|
||||
final void Function(Comment)? onRightMoreTapped;
|
||||
final void Function(String) onStoryLinkTapped;
|
||||
final FetchMode fetchMode;
|
||||
|
||||
@override
|
||||
@ -135,8 +135,9 @@ class CommentTile extends StatelessWidget {
|
||||
Text(
|
||||
comment.by,
|
||||
style: TextStyle(
|
||||
color:
|
||||
prefState.showEyeCandy ? orange : color,
|
||||
color: prefState.eyeCandyEnabled
|
||||
? orange
|
||||
: color,
|
||||
),
|
||||
),
|
||||
if (comment.by == opUsername)
|
||||
@ -328,7 +329,7 @@ class CommentTile extends StatelessWidget {
|
||||
final double commentBackgroundColorOpacity =
|
||||
Theme.of(context).brightness == Brightness.dark ? 0.03 : 0.15;
|
||||
|
||||
final Color commentColor = prefState.showEyeCandy
|
||||
final Color commentColor = prefState.eyeCandyEnabled
|
||||
? color.withOpacity(commentBackgroundColorOpacity)
|
||||
: Palette.transparent;
|
||||
final bool isMyComment = myUsername == comment.by;
|
||||
@ -406,7 +407,7 @@ class CommentTile extends StatelessWidget {
|
||||
}
|
||||
|
||||
void onTextTapped(BuildContext context) {
|
||||
if (context.read<PreferenceCubit>().state.tapAnywhereToCollapse) {
|
||||
if (context.read<PreferenceCubit>().state.tapAnywhereToCollapseEnabled) {
|
||||
HapticFeedback.selectionClick();
|
||||
context.read<CollapseCubit>().collapse();
|
||||
}
|
||||
|
181
lib/screens/widgets/custom_tab_bar.dart
Normal file
181
lib/screens/widgets/custom_tab_bar.dart
Normal 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();
|
||||
}
|
||||
}
|
@ -5,6 +5,8 @@ import 'package:flutter_fadein/flutter_fadein.dart';
|
||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||
import 'package:hacki/blocs/blocs.dart';
|
||||
import 'package:hacki/cubits/cubits.dart';
|
||||
import 'package:hacki/extensions/context_extension.dart';
|
||||
import 'package:hacki/models/models.dart';
|
||||
import 'package:hacki/screens/widgets/widgets.dart';
|
||||
import 'package:hacki/styles/styles.dart';
|
||||
@ -31,6 +33,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
||||
this.onLoadMore,
|
||||
this.onPinned,
|
||||
this.header,
|
||||
this.onMoreTapped,
|
||||
}) : assert(
|
||||
!pinnable || (pinnable && onPinned != null),
|
||||
'onPinned cannot be null when pinnable is true',
|
||||
@ -57,7 +60,10 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
||||
final VoidCallback? onRefresh;
|
||||
final VoidCallback? onLoadMore;
|
||||
final ValueChanged<Story>? onPinned;
|
||||
final Function(T) onTap;
|
||||
final void Function(T) onTap;
|
||||
|
||||
/// Used for home screen.
|
||||
final void Function(Story, Rect?)? onMoreTapped;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -71,39 +77,57 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
||||
...items.map((T e) {
|
||||
if (e is Story) {
|
||||
final bool hasRead = context.read<StoriesBloc>().hasRead(e);
|
||||
final bool swipeGestureEnabled =
|
||||
context.read<PreferenceCubit>().state.swipeGestureEnabled;
|
||||
return <Widget>[
|
||||
FadeIn(
|
||||
child: Slidable(
|
||||
startActionPane: pinnable
|
||||
? ActionPane(
|
||||
motion: const BehindMotion(),
|
||||
children: <Widget>[
|
||||
SlidableAction(
|
||||
onPressed: (_) {
|
||||
HapticFeedback.lightImpact();
|
||||
onPinned?.call(e);
|
||||
},
|
||||
backgroundColor: Palette.orange,
|
||||
foregroundColor: Palette.white,
|
||||
icon: showWebPreview
|
||||
? Icons.push_pin_outlined
|
||||
: null,
|
||||
label: 'Pin to top',
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
child: StoryTile(
|
||||
key: ValueKey<int>(e.id),
|
||||
story: e,
|
||||
onTap: () => onTap(e),
|
||||
showWebPreview: showWebPreview,
|
||||
showMetadata: showMetadata,
|
||||
showUrl: showUrl,
|
||||
hasRead: markReadStories && hasRead,
|
||||
simpleTileFontSize: useConsistentFontSize
|
||||
? TextDimens.pt14
|
||||
: TextDimens.pt16,
|
||||
GestureDetector(
|
||||
/// If swipe gesture is enabled on home screen, use long press
|
||||
/// instead of slide action to trigger the action menu.
|
||||
onLongPress: swipeGestureEnabled
|
||||
? () => onMoreTapped?.call(e, context.rect)
|
||||
: null,
|
||||
child: FadeIn(
|
||||
child: Slidable(
|
||||
enabled: !swipeGestureEnabled,
|
||||
startActionPane: pinnable
|
||||
? ActionPane(
|
||||
motion: const BehindMotion(),
|
||||
children: <Widget>[
|
||||
SlidableAction(
|
||||
onPressed: (_) {
|
||||
HapticFeedback.lightImpact();
|
||||
onPinned?.call(e);
|
||||
},
|
||||
backgroundColor: Palette.orange,
|
||||
foregroundColor: Palette.white,
|
||||
icon: showWebPreview
|
||||
? Icons.push_pin_outlined
|
||||
: null,
|
||||
label: showWebPreview ? null : 'Pin to top',
|
||||
),
|
||||
SlidableAction(
|
||||
onPressed: (_) =>
|
||||
onMoreTapped?.call(e, context.rect),
|
||||
backgroundColor: Palette.orange,
|
||||
foregroundColor: Palette.white,
|
||||
icon: showWebPreview ? Icons.more_horiz : null,
|
||||
label: showWebPreview ? null : 'More',
|
||||
),
|
||||
],
|
||||
)
|
||||
: null,
|
||||
child: StoryTile(
|
||||
key: ValueKey<int>(e.id),
|
||||
story: e,
|
||||
onTap: () => onTap(e),
|
||||
showWebPreview: showWebPreview,
|
||||
showMetadata: showMetadata,
|
||||
showUrl: showUrl,
|
||||
hasRead: markReadStories && hasRead,
|
||||
simpleTileFontSize: useConsistentFontSize
|
||||
? TextDimens.pt14
|
||||
: TextDimens.pt16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -249,11 +273,10 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
||||
|
||||
class _CommentTile extends StatelessWidget {
|
||||
const _CommentTile({
|
||||
Key? key,
|
||||
required this.comment,
|
||||
required this.onTap,
|
||||
this.fontSize = 16,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
final Comment comment;
|
||||
final VoidCallback onTap;
|
||||
|
@ -113,7 +113,8 @@ class LinkPreview extends StatefulWidget {
|
||||
|
||||
class _LinkPreviewState extends State<LinkPreview> {
|
||||
InfoBase? _info;
|
||||
String? _errorTitle, _errorBody;
|
||||
String? _errorTitle;
|
||||
String? _errorBody;
|
||||
bool _loading = false;
|
||||
|
||||
@override
|
||||
@ -158,7 +159,7 @@ class _LinkPreviewState extends State<LinkPreview> {
|
||||
}
|
||||
|
||||
Widget _buildLinkContainer(
|
||||
double _height, {
|
||||
double height, {
|
||||
String? title = '',
|
||||
String? desc = '',
|
||||
String? imageUri = '',
|
||||
@ -177,7 +178,7 @@ class _LinkPreviewState extends State<LinkPreview> {
|
||||
const BoxShadow(blurRadius: 3, color: Palette.grey),
|
||||
],
|
||||
),
|
||||
height: _height,
|
||||
height: height,
|
||||
child: LinkView(
|
||||
key: widget.key ?? Key(widget.link),
|
||||
metadata: widget.story.simpleMetadata,
|
||||
|
@ -37,7 +37,7 @@ class LinkView extends StatelessWidget {
|
||||
final String description;
|
||||
final String? imageUri;
|
||||
final String? imagePath;
|
||||
final Function(String) onTap;
|
||||
final void Function(String) onTap;
|
||||
final TextStyle? titleTextStyle;
|
||||
final TextStyle? bodyTextStyle;
|
||||
final bool showMultiMedia;
|
||||
@ -76,13 +76,13 @@ class LinkView extends StatelessWidget {
|
||||
final double layoutWidth = constraints.biggest.width;
|
||||
final double layoutHeight = constraints.biggest.height;
|
||||
|
||||
final TextStyle _titleFontSize = titleTextStyle ??
|
||||
final TextStyle titleFontSize = titleTextStyle ??
|
||||
TextStyle(
|
||||
fontSize: computeTitleFontSize(layoutWidth),
|
||||
color: Palette.black,
|
||||
fontWeight: FontWeight.bold,
|
||||
);
|
||||
final TextStyle _bodyFontSize = bodyTextStyle ??
|
||||
final TextStyle bodyFontSize = bodyTextStyle ??
|
||||
TextStyle(
|
||||
fontSize: computeTitleFontSize(layoutWidth) - 1,
|
||||
color: Palette.grey,
|
||||
@ -131,11 +131,11 @@ class LinkView extends StatelessWidget {
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
_buildTitleContainer(
|
||||
_titleFontSize,
|
||||
titleFontSize,
|
||||
computeTitleLines(layoutHeight),
|
||||
),
|
||||
_buildBodyContainer(
|
||||
_bodyFontSize,
|
||||
bodyFontSize,
|
||||
computeBodyLines(layoutHeight),
|
||||
)
|
||||
],
|
||||
@ -149,7 +149,7 @@ class LinkView extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTitleContainer(TextStyle _titleTS, int _maxLines) {
|
||||
Widget _buildTitleContainer(TextStyle titleTS, int maxLines) {
|
||||
final bool showUrl = this.showUrl && url.isNotEmpty;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 2, 3, 0),
|
||||
@ -159,9 +159,9 @@ class LinkView extends StatelessWidget {
|
||||
alignment: Alignment.topLeft,
|
||||
child: Text(
|
||||
title,
|
||||
style: _titleTS,
|
||||
style: titleTS,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: _maxLines,
|
||||
maxLines: maxLines,
|
||||
),
|
||||
),
|
||||
if (showUrl)
|
||||
@ -170,10 +170,10 @@ class LinkView extends StatelessWidget {
|
||||
child: Text(
|
||||
'($readableUrl)',
|
||||
textAlign: TextAlign.left,
|
||||
style: _titleTS.copyWith(
|
||||
style: titleTS.copyWith(
|
||||
color: Palette.grey,
|
||||
fontSize:
|
||||
_titleTS.fontSize == null ? 12 : _titleTS.fontSize! - 4,
|
||||
titleTS.fontSize == null ? 12 : titleTS.fontSize! - 4,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
overflow: bodyTextOverflow ?? TextOverflow.ellipsis,
|
||||
@ -185,7 +185,7 @@ class LinkView extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBodyContainer(TextStyle _bodyTS, int _maxLines) {
|
||||
Widget _buildBodyContainer(TextStyle bodyTS, int maxLines) {
|
||||
return Expanded(
|
||||
flex: 2,
|
||||
child: Padding(
|
||||
@ -198,9 +198,9 @@ class LinkView extends StatelessWidget {
|
||||
child: Text(
|
||||
metadata,
|
||||
textAlign: TextAlign.left,
|
||||
style: _bodyTS.copyWith(
|
||||
style: bodyTS.copyWith(
|
||||
fontSize:
|
||||
_bodyTS.fontSize == null ? 12 : _bodyTS.fontSize! - 2,
|
||||
bodyTS.fontSize == null ? 12 : bodyTS.fontSize! - 2,
|
||||
),
|
||||
overflow: bodyTextOverflow ?? TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
@ -212,9 +212,9 @@ class LinkView extends StatelessWidget {
|
||||
child: Text(
|
||||
description,
|
||||
textAlign: TextAlign.left,
|
||||
style: _bodyTS,
|
||||
style: bodyTS,
|
||||
overflow: bodyTextOverflow ?? TextOverflow.ellipsis,
|
||||
maxLines: (bodyMaxLines ?? _maxLines) -
|
||||
maxLines: (bodyMaxLines ?? maxLines) -
|
||||
(showMetadata ? 1 : 0) -
|
||||
(showUrl && url.isNotEmpty ? 1 : 0),
|
||||
),
|
||||
|
@ -63,7 +63,7 @@ class WebImageInfo extends InfoBase {
|
||||
|
||||
/// Video Information
|
||||
class WebVideoInfo extends WebImageInfo {
|
||||
WebVideoInfo({String? image}) : super(image: image);
|
||||
WebVideoInfo({super.image});
|
||||
}
|
||||
|
||||
/// Web analyzer
|
||||
|
@ -6,7 +6,7 @@ import 'package:hacki/styles/styles.dart';
|
||||
import 'package:hacki/utils/utils.dart';
|
||||
|
||||
class OnboardingView extends StatefulWidget {
|
||||
const OnboardingView({Key? key}) : super(key: key);
|
||||
const OnboardingView({super.key});
|
||||
|
||||
@override
|
||||
State<OnboardingView> createState() => _OnboardingViewState();
|
||||
@ -106,10 +106,9 @@ class _OnboardingViewState extends State<OnboardingView> {
|
||||
|
||||
class _PageViewChild extends StatelessWidget {
|
||||
const _PageViewChild({
|
||||
Key? key,
|
||||
required this.path,
|
||||
required this.description,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
final String path;
|
||||
final String description;
|
||||
|
@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
@ -56,7 +56,7 @@ class StoryTile extends StatelessWidget {
|
||||
titleStyle: TextStyle(
|
||||
color: hasRead
|
||||
? Palette.grey[500]
|
||||
: Theme.of(context).textTheme.subtitle1?.color,
|
||||
: Theme.of(context).textTheme.bodyLarge?.color,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
showMetadata: showMetadata,
|
||||
@ -90,7 +90,7 @@ class StoryTile extends StatelessWidget {
|
||||
? Palette.grey[500]
|
||||
: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyText1
|
||||
.bodyLarge
|
||||
?.color,
|
||||
fontSize: simpleTileFontSize,
|
||||
),
|
||||
@ -137,9 +137,8 @@ class StoryTile extends StatelessWidget {
|
||||
|
||||
class _LinkPreviewPlaceholder extends StatelessWidget {
|
||||
const _LinkPreviewPlaceholder({
|
||||
Key? key,
|
||||
required this.height,
|
||||
}) : super(key: key);
|
||||
});
|
||||
|
||||
final double height;
|
||||
|
||||
|
@ -4,9 +4,9 @@ export 'comment_tile.dart';
|
||||
export 'countdown_reminder.dart';
|
||||
export 'custom_chip.dart';
|
||||
export 'custom_circular_progress_indicator.dart';
|
||||
export 'custom_tab_bar.dart';
|
||||
export 'items_list_view.dart';
|
||||
export 'link_preview/link_preview.dart';
|
||||
export 'link_preview/link_preview.dart';
|
||||
export 'offline_banner.dart';
|
||||
export 'onboarding_view.dart';
|
||||
export 'spring_curve.dart';
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
),
|
||||
|
@ -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',
|
||||
),
|
||||
|
@ -38,3 +38,8 @@ abstract class TextDimens {
|
||||
static const double pt26 = 26;
|
||||
static const double pt36 = 36;
|
||||
}
|
||||
|
||||
abstract class NumSwitch {
|
||||
static const double on = 1;
|
||||
static const double off = 0;
|
||||
}
|
||||
|
@ -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
54
lib/utils/log_util.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
@ -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';
|
||||
|
769
pubspec.lock
769
pubspec.lock
File diff suppressed because it is too large
Load Diff
39
pubspec.yaml
39
pubspec.yaml
@ -1,20 +1,20 @@
|
||||
name: hacki
|
||||
description: A Hacker News reader.
|
||||
version: 1.0.2+80
|
||||
version: 1.0.8+86
|
||||
publish_to: none
|
||||
|
||||
environment:
|
||||
sdk: ">=2.17.0 <3.0.0"
|
||||
flutter: "3.3.10"
|
||||
flutter: "3.7.2"
|
||||
|
||||
dependencies:
|
||||
adaptive_theme: ^3.0.0
|
||||
badges: ^2.0.2
|
||||
badges: ^3.0.2
|
||||
bloc: ^8.1.0
|
||||
cached_network_image: ^3.2.1
|
||||
clipboard: ^0.1.3
|
||||
collection: ^1.16.0
|
||||
connectivity_plus: ^2.3.7
|
||||
collection: ^1.17.0
|
||||
connectivity_plus: ^3.0.2
|
||||
dio: ^4.0.4
|
||||
equatable: ^2.0.5
|
||||
fast_gbk: ^1.0.0
|
||||
@ -26,15 +26,16 @@ dependencies:
|
||||
sdk: flutter
|
||||
flutter_bloc: ^8.1.1
|
||||
flutter_cache_manager: ^3.3.0
|
||||
flutter_email_sender: ^5.2.0
|
||||
flutter_fadein: ^2.0.0
|
||||
flutter_feather_icons: 2.0.0+1
|
||||
flutter_inappwebview: ^5.7.2+3
|
||||
flutter_linkify: ^5.0.2
|
||||
flutter_local_notifications: ^9.5.0
|
||||
flutter_secure_storage: ^6.0.0
|
||||
flutter_local_notifications: ^13.0.0
|
||||
flutter_secure_storage: ^7.0.1
|
||||
flutter_siri_suggestions: ^2.1.0
|
||||
flutter_slidable: ^2.0.0
|
||||
font_awesome_flutter: ^9.2.0
|
||||
font_awesome_flutter: ^10.3.0
|
||||
gbk_codec: ^0.4.0
|
||||
get_it: 7.2.0
|
||||
hive: ^2.0.6
|
||||
@ -42,25 +43,25 @@ dependencies:
|
||||
html_unescape: ^2.0.0
|
||||
http: ^0.13.3
|
||||
hydrated_bloc: ^9.0.0-dev.3
|
||||
intl: ^0.17.0
|
||||
intl: ^0.18.0
|
||||
logger: ^1.1.0
|
||||
package_info_plus: ^3.0.2
|
||||
path: ^1.8.0
|
||||
path: ^1.8.2
|
||||
path_provider: ^2.0.8
|
||||
path_provider_android: ^2.0.8
|
||||
path_provider_ios: ^2.0.8
|
||||
path_provider_foundation: ^2.1.1
|
||||
pull_to_refresh:
|
||||
git:
|
||||
url: https://github.com/livinglist/flutter_pulltorefresh
|
||||
ref: master
|
||||
receive_sharing_intent: ^1.4.5
|
||||
responsive_builder: ^0.4.2
|
||||
responsive_builder: ^0.5.0+1
|
||||
rxdart: ^0.27.3
|
||||
sembast: ^3.1.1+1
|
||||
share_plus: ^4.0.8
|
||||
shared_preferences: ^2.0.11
|
||||
shared_preferences_android: ^2.0.11
|
||||
shared_preferences_ios: ^2.0.11
|
||||
share_plus: ^6.3.0
|
||||
shared_preferences: ^2.0.17
|
||||
shared_preferences_android: ^2.0.15
|
||||
shared_preferences_foundation: ^2.1.2
|
||||
shimmer: ^2.0.0
|
||||
synced_shared_preferences:
|
||||
path: components/synced_shared_preferences
|
||||
@ -68,17 +69,19 @@ dependencies:
|
||||
universal_platform: ^1.0.0+1
|
||||
url_launcher: ^6.1.3
|
||||
wakelock: ^0.6.1+2
|
||||
webview_flutter: ^3.0.4
|
||||
webview_flutter: ^4.0.2
|
||||
workmanager: ^0.5.0
|
||||
|
||||
dev_dependencies:
|
||||
bloc_test: ^9.1.0
|
||||
flutter_driver:
|
||||
sdk: flutter
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
integration_test:
|
||||
sdk: flutter
|
||||
mocktail: ^0.3.0
|
||||
very_good_analysis: ^2.4.0
|
||||
very_good_analysis: ^3.1.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
|
Submodule submodules/flutter updated: 135454af32...32fb2f948e
@ -20,8 +20,11 @@ void main() {
|
||||
final MockStoriesRepository mockStoriesRepository = MockStoriesRepository();
|
||||
final MockSembastRepository mockSembastRepository = MockSembastRepository();
|
||||
|
||||
const int created = 0, delay = 1, karma = 2;
|
||||
const String about = 'about', id = 'id';
|
||||
const int created = 0;
|
||||
const int delay = 1;
|
||||
const int karma = 2;
|
||||
const String about = 'about';
|
||||
const String id = 'id';
|
||||
|
||||
const User tUser = User(
|
||||
about: about,
|
||||
@ -57,7 +60,8 @@ void main() {
|
||||
);
|
||||
|
||||
group('AuthAppStarted', () {
|
||||
const String username = 'username', password = 'password';
|
||||
const String username = 'username';
|
||||
const String password = 'password';
|
||||
setUp(() {
|
||||
when(() => mockAuthRepository.username)
|
||||
.thenAnswer((_) => Future<String?>.value(username));
|
||||
|
Reference in New Issue
Block a user