Compare commits

...

21 Commits

Author SHA1 Message Date
268f4054a3 improve story marking. (#253) 2023-09-11 20:42:33 -07:00
988c5d9881 add haptic feedback. (#252) 2023-09-11 18:08:21 -07:00
e748e2f818 allow swipe gesture in fav screen. (#251) 2023-09-11 17:01:42 -07:00
1b0a0dbda9 add changelog. (#250) 2023-09-11 15:22:32 -07:00
64d68389ba migrate from Navigator to GoRouter (#249) 2023-09-10 22:26:46 -07:00
381c99b353 fix crashing. (#248) 2023-09-08 09:07:48 -07:00
39ee3137f8 fix reply box in full screen. (#247) 2023-09-05 15:17:23 -07:00
0d76be8634 bump flutter version. (#243) 2023-08-22 06:54:59 -07:00
9986f72e11 improve shortcut buttons. (#242) 2023-07-19 21:09:24 -07:00
ef557e7b84 fix text scaling and url parsing. (#237) 2023-07-10 10:18:12 -07:00
ec065c0122 fix QR code view. (#231) 2023-06-22 19:00:19 -07:00
2960c6e59e add ability to import favorites using QR code. (#230) 2023-06-22 18:14:09 -07:00
92dac6b932 update device_gesture_wrapper.dart (#227) 2023-06-06 18:42:29 -07:00
20365393a3 fix capitalization. (#226) 2023-06-05 21:46:01 -07:00
8d238744c7 add option to disable auto-scroll. (#225) 2023-06-05 18:23:29 -07:00
e33ff417fb update project.pbxproj (#224) 2023-06-04 21:31:14 -07:00
d8922c2641 prevent over scrolling after collapsing a comment. (#223) 2023-06-04 19:16:01 -07:00
c6e0461857 improve date time range picker in search screen and add monochrome icons. (#219) 2023-05-26 21:33:43 -08:00
30ca356dc8 add date filter shortcuts. (#218) 2023-05-26 13:44:50 -08:00
7d11398e6d fix comment tile. (#215) 2023-05-19 12:37:21 -07:00
a4f52284ef bump flutter version. (#214) 2023-05-18 17:00:47 -07:00
134 changed files with 2129 additions and 1510 deletions

View File

@ -9,13 +9,14 @@ on:
jobs:
releases:
name: Check commit
runs-on: ubuntu-latest
runs-on: macos-latest
timeout-minutes: 30
steps:
- name: checkout all the submodules
uses: actions/checkout@v3
with:
submodules: recursive
fetch-depth: 0
- run: submodules/flutter/bin/flutter doctor
- run: submodules/flutter/bin/flutter pub get
- run: submodules/flutter/bin/dart format --set-exit-if-changed lib test integration_test

View File

@ -23,6 +23,7 @@ jobs:
uses: actions/checkout@v3
with:
submodules: recursive
fetch-depth: 0
- run: submodules/flutter/bin/flutter doctor
- run: submodules/flutter/bin/flutter pub get
- run: submodules/flutter/bin/dart format --set-exit-if-changed lib test integration_test

View File

@ -29,6 +29,7 @@ Features:
- Download stories and comments for offline reading.
- Pick up where you left off.
- Synced favorites and pins across devices. (iOS only)
- Export or import your favorites.
- Launch from system share sheet.
- And more...

View File

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

View File

@ -2,4 +2,5 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@mipmap/ic_launcher_adaptive_back"/>
<foreground android:drawable="@mipmap/ic_launcher_adaptive_fore"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 940 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

View File

@ -24,6 +24,6 @@ subprojects {
project.evaluationDependsOn(':app')
}
task clean(type: Delete) {
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

BIN
assets/hacki-github.xcf Normal file

Binary file not shown.

BIN
assets/hacki.xcf Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 890 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 873 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 517 KiB

View File

@ -1,108 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:in_app_review/in_app_review.dart';
import 'package:in_app_review_platform_interface/in_app_review_platform_interface.dart';
import 'package:mockito/mockito.dart';
import 'package:plugin_platform_interface/plugin_platform_interface.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
final inAppReview = InAppReview.instance;
late MockInAppReviewPlatform platform;
setUp(() {
platform = MockInAppReviewPlatform();
InAppReviewPlatform.instance = platform;
});
tearDown(() {
verifyNoMoreInteractions(platform);
});
group('isAvailable', () {
test(
'should call InAppReviewPlatform.isAvailable()',
() async {
// ARRANGE
when(platform.isAvailable()).thenAnswer((_) async => true);
// ACT
final result = await inAppReview.isAvailable();
// ASSERT
verify(platform.isAvailable());
expect(result, isTrue);
},
);
});
group('requestReview', () {
test(
'should call InAppReviewPlatform.requestReview()',
() async {
// ARRANGE
when(platform.requestReview()).thenAnswer((_) async {});
// ACT
await inAppReview.requestReview();
// ASSERT
verify(platform.requestReview());
},
);
});
group('openStoreListing', () {
test(
'should call InAppReviewPlatform.openStoreListing()',
() async {
// ARRANGE
const appStoreId = 'app_store_id';
const microsoftStoreId = 'microsoft_store_id';
when(platform.openStoreListing(
appStoreId: appStoreId,
microsoftStoreId: microsoftStoreId,
)).thenAnswer((_) async {});
// ACT
await inAppReview.openStoreListing(
appStoreId: appStoreId,
microsoftStoreId: microsoftStoreId,
);
// ASSERT
verify(platform.openStoreListing(
appStoreId: appStoreId,
microsoftStoreId: microsoftStoreId,
));
},
);
});
}
class MockInAppReviewPlatform extends Mock
with MockPlatformInterfaceMixin
implements InAppReviewPlatform {
@override
Future<bool> isAvailable() => super.noSuchMethod(
Invocation.method(#isAvailable, null),
returnValue: Future.value(true),
);
@override
Future<void> requestReview() => super.noSuchMethod(
Invocation.method(#requestReview, null),
returnValue: Future<void>.value(),
);
@override
Future<void> openStoreListing({
String? appStoreId,
String? microsoftStoreId,
}) =>
super.noSuchMethod(
Invocation.method(
#openStoreListing,
null,
{#appStoreId: appStoreId, #microsoftStoreId: microsoftStoreId},
),
returnValue: Future<void>.value(),
);
}

View File

@ -1,136 +0,0 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:in_app_review_platform_interface/method_channel_in_app_review.dart';
import 'package:platform/platform.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late MethodChannelInAppReview methodChannelInAppReview;
late List<MethodCall> log = <MethodCall>[];
const MethodChannel channel = MethodChannel('dev.britannio.in_app_review');
setUp(() {
methodChannelInAppReview = MethodChannelInAppReview();
methodChannelInAppReview.channel = channel;
log = <MethodCall>[];
});
tearDown(() {
log.clear();
});
channel.setMockMethodCallHandler((MethodCall call) async {
log.add(call);
switch (call.method) {
case 'isAvailable':
return true;
case 'requestReview':
case 'openStoreListing':
return null;
default:
assert(false);
return null;
}
});
group('isAvailable', () {
test(
'should invoke the isAvailable method channel',
() async {
// ACT
final bool result = await methodChannelInAppReview.isAvailable();
// ASSERT
expect(log, <Matcher>[isMethodCall('isAvailable', arguments: null)]);
expect(result, isTrue);
},
);
});
group('requestReview', () {
test(
'should invoke the requestReview method channel',
() async {
// ACT
await methodChannelInAppReview.requestReview();
// ASSERT
expect(log, <Matcher>[isMethodCall('requestReview', arguments: null)]);
},
);
});
group('openStoreListing', () {
test(
'should invoke the openStoreListing method channel on Android',
() async {
// ARRANGE
methodChannelInAppReview.platform =
FakePlatform(operatingSystem: 'android');
// ACT
await methodChannelInAppReview.openStoreListing();
// ASSERT
expect(
log,
<Matcher>[isMethodCall('openStoreListing', arguments: null)],
);
},
);
test(
'should invoke the openStoreListing method channel on iOS',
() async {
// ARRANGE
methodChannelInAppReview.platform =
FakePlatform(operatingSystem: 'ios');
final String appStoreId = "store_id";
// ACT
await methodChannelInAppReview.openStoreListing(appStoreId: appStoreId);
// ASSERT
expect(log,
<Matcher>[isMethodCall('openStoreListing', arguments: appStoreId)]);
},
);
test(
'should invoke the openStoreListing method channel on MacOS',
() async {
// ARRANGE
methodChannelInAppReview.platform =
FakePlatform(operatingSystem: 'macos');
final String appStoreId = "store_id";
// ACT
await methodChannelInAppReview.openStoreListing(appStoreId: appStoreId);
// ASSERT
expect(log,
<Matcher>[isMethodCall('openStoreListing', arguments: appStoreId)]);
},
);
test(
'should invoke the openStoreListing method channel on Windows',
() async {
// ARRANGE
methodChannelInAppReview.platform =
FakePlatform(operatingSystem: 'windows');
final String microsoftStoreId = 'store_id';
// ACT
await methodChannelInAppReview.openStoreListing(
microsoftStoreId: microsoftStoreId,
);
// ASSERT
expect(log, <Matcher>[
isMethodCall('openStoreListing', arguments: microsoftStoreId)
]);
},
skip:
'The windows uwp implementation still uses the url_launcher package',
);
});
}

View File

@ -0,0 +1 @@
- Ability to mark a story as read once scrolling past.

View File

@ -27,12 +27,16 @@ PODS:
- Flutter
- integration_test (0.0.1):
- Flutter
- MTBBarcodeScanner (5.0.11)
- OrderedSet (5.0.0)
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- qr_code_scanner (0.2.0):
- Flutter
- MTBBarcodeScanner
- ReachabilitySwift (5.0.0)
- receive_sharing_intent (0.0.1):
- Flutter
@ -41,7 +45,7 @@ PODS:
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite (0.0.2):
- sqflite (0.0.3):
- Flutter
- FMDB (>= 2.7.5)
- synced_shared_preferences (0.0.1):
@ -67,10 +71,11 @@ DEPENDENCIES:
- in_app_review (from `.symlinks/plugins/in_app_review/ios`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- qr_code_scanner (from `.symlinks/plugins/qr_code_scanner/ios`)
- receive_sharing_intent (from `.symlinks/plugins/receive_sharing_intent/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- 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`)
@ -81,6 +86,7 @@ DEPENDENCIES:
SPEC REPOS:
trunk:
- FMDB
- MTBBarcodeScanner
- OrderedSet
- ReachabilitySwift
@ -108,13 +114,15 @@ EXTERNAL SOURCES:
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/ios"
:path: ".symlinks/plugins/path_provider_foundation/darwin"
qr_code_scanner:
:path: ".symlinks/plugins/qr_code_scanner/ios"
receive_sharing_intent:
:path: ".symlinks/plugins/receive_sharing_intent/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/ios"
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite:
:path: ".symlinks/plugins/sqflite/ios"
synced_shared_preferences:
@ -129,8 +137,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/workmanager/ios"
SPEC CHECKSUMS:
connectivity_plus: 413a8857dd5d9f1c399a39130850d02fe0feaf7e
device_info_plus: e5c5da33f982a436e103237c0c85f9031142abed
connectivity_plus: 07c49e96d7fc92bc9920617b83238c4d178b446a
device_info_plus: 7545d84d8d1b896cb16a4ff98c19f07ec4b298ea
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
flutter_email_sender: 02d7443217d8c41483223627972bfdc09f74276b
flutter_inappwebview: bfd58618f49dc62f2676de690fc6dcda1d6c3721
@ -139,19 +147,21 @@ SPEC CHECKSUMS:
flutter_siri_suggestions: 226fb7ef33d25d3fe0d4aa2a8bcf4b72730c466f
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d
integration_test: a1e7d09bd98eca2fc37aefd79d4f41ad37bdbbe5
integration_test: 13825b8a9334a850581300559b8839134b124670
MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb
OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c
package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
path_provider_foundation: 37748e03f12783f9de2cb2c4eadfaa25fe6d4852
package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
qr_code_scanner: bb67d64904c3b9658ada8c402e8b4d406d5d796e
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
receive_sharing_intent: c0d87310754e74c0f9542947e7cbdf3a0335a3b1
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68
shared_preferences_foundation: 297b3ebca31b34ec92be11acd7fb0ba932c822ca
sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
share_plus: 599aa54e4ea31d4b4c0e9c911bcc26c55e791028
shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126
sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
synced_shared_preferences: f722742b06d65c7315b8e9f56b794c9fbd5597f7
url_launcher_ios: fb12c43172927bb5cf75aeebd073f883801f1993
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
wakelock: d0fc7c864128eac40eba1617cb5264d9c940b46f
webview_flutter_wkwebview: b7e70ef1ddded7e69c796c7390ee74180182971f
webview_flutter_wkwebview: 2e2d318f21a5e036e2c3f26171342e95908bd60a
workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6
PODFILE CHECKSUM: d28e9a1c7bee335d05ddd795703aad5bf05bb937

View File

@ -10,6 +10,7 @@
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7A6CD5D595D5F4E8710804C0 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8BF0A917F40A838BF30D8F4C /* Pods_Runner.framework */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
@ -22,7 +23,6 @@
E530B1B0283B54DA004E8EB6 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = E530B1AE283B54DA004E8EB6 /* MainInterface.storyboard */; };
E530B1B4283B54DA004E8EB6 /* Action Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = E530B1A6283B54DA004E8EB6 /* Action Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
E575B6F127EBC6DB002B1508 /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E575B6F027EBC6DA002B1508 /* CloudKit.framework */; };
FC507E94AA7767C155787DB3 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BFB5AA41D6C22D228077D166 /* Pods_Runner.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -68,14 +68,14 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
027B292CC58CF92F11FC0A69 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
0E63A5CE3FDBCCD054072136 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
4449F5D4D39C23F292D07005 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
8BF0A917F40A838BF30D8F4C /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
@ -83,8 +83,8 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
BFB5AA41D6C22D228077D166 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
DF5D5FFF325B7D5DFEE88A3F /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
B9EC882BDD04A309C317E416 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
D73EA9FA5E6F35364DCA0CD1 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
E51D52AD283B464E00FC8DD8 /* Share Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "Share Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; };
E51D52AF283B464E00FC8DD8 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
E51D52B2283B464E00FC8DD8 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = "<group>"; };
@ -107,7 +107,7 @@
buildActionMask = 2147483647;
files = (
E575B6F127EBC6DB002B1508 /* CloudKit.framework in Frameworks */,
FC507E94AA7767C155787DB3 /* Pods_Runner.framework in Frameworks */,
7A6CD5D595D5F4E8710804C0 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -183,8 +183,8 @@
isa = PBXGroup;
children = (
E575B6F027EBC6DA002B1508 /* CloudKit.framework */,
BFB5AA41D6C22D228077D166 /* Pods_Runner.framework */,
E530B1A7283B54DA004E8EB6 /* UniformTypeIdentifiers.framework */,
8BF0A917F40A838BF30D8F4C /* Pods_Runner.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@ -192,9 +192,9 @@
D79CD63C88FF49EF451AFDDF /* Pods */ = {
isa = PBXGroup;
children = (
DF5D5FFF325B7D5DFEE88A3F /* Pods-Runner.debug.xcconfig */,
4449F5D4D39C23F292D07005 /* Pods-Runner.release.xcconfig */,
027B292CC58CF92F11FC0A69 /* Pods-Runner.profile.xcconfig */,
0E63A5CE3FDBCCD054072136 /* Pods-Runner.debug.xcconfig */,
D73EA9FA5E6F35364DCA0CD1 /* Pods-Runner.release.xcconfig */,
B9EC882BDD04A309C317E416 /* Pods-Runner.profile.xcconfig */,
);
path = Pods;
sourceTree = "<group>";
@ -229,15 +229,15 @@
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
41DC8215F9CFD708C36ECBA8 /* [CP] Check Pods Manifest.lock */,
E2E6E097A94005D9196D0A71 /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
7714A105B2069B720D0DF18E /* [CP] Embed Pods Frameworks */,
E51D52B8283B464E00FC8DD8 /* Embed App Extensions */,
F1959755D5521D58CA193498 /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@ -291,7 +291,7 @@
isa = PBXProject;
attributes = {
LastSwiftUpdateCheck = 1330;
LastUpgradeCheck = 1300;
LastUpgradeCheck = 1430;
ORGANIZATIONNAME = "";
TargetAttributes = {
97C146ED1CF9000F007C117D = {
@ -365,6 +365,7 @@
files = (
);
inputPaths = (
"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
);
name = "Thin Binary";
outputPaths = (
@ -373,7 +374,22 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
41DC8215F9CFD708C36ECBA8 /* [CP] Check Pods Manifest.lock */ = {
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
E2E6E097A94005D9196D0A71 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@ -395,7 +411,7 @@
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
7714A105B2069B720D0DF18E /* [CP] Embed Pods Frameworks */ = {
F1959755D5521D58CA193498 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
@ -412,21 +428,6 @@
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputPaths = (
);
name = "Run Script";
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@ -565,11 +566,9 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Hacki;
@ -583,7 +582,6 @@
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development com.jiaqi.hacki";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
@ -707,11 +705,9 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Hacki;
@ -725,7 +721,6 @@
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match Development com.jiaqi.hacki";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 5.0;
@ -780,11 +775,9 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = "Share Extension/Share Extension.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
DEVELOPMENT_TEAM = QMWX3X2NF7;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Share Extension/Info.plist";
@ -802,7 +795,6 @@
PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Share-Extension";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "hacki share extension profile";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_EMIT_LOC_STRINGS = YES;
@ -863,11 +855,9 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = "Share Extension/Share Extension.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
DEVELOPMENT_TEAM = QMWX3X2NF7;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Share Extension/Info.plist";
@ -884,7 +874,6 @@
PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Share-Extension";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "hacki share extension profile";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
@ -905,11 +894,9 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = "Action Extension/Action Extension.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
DEVELOPMENT_TEAM = QMWX3X2NF7;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Action Extension/Info.plist";
@ -927,7 +914,6 @@
PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Action-Extension";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "hacki action extension profile";
SKIP_INSTALL = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_EMIT_LOC_STRINGS = YES;
@ -992,11 +978,9 @@
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
CODE_SIGN_ENTITLEMENTS = "Action Extension/Action Extension.entitlements";
CODE_SIGN_IDENTITY = "Apple Development";
"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
CODE_SIGN_STYLE = Manual;
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = "";
"DEVELOPMENT_TEAM[sdk=iphoneos*]" = QMWX3X2NF7;
DEVELOPMENT_TEAM = QMWX3X2NF7;
GCC_C_LANGUAGE_STANDARD = gnu11;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "Action Extension/Info.plist";
@ -1013,7 +997,6 @@
PRODUCT_BUNDLE_IDENTIFIER = "com.jiaqi.hacki.Action-Extension";
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
"PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "hacki action extension profile";
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;

View File

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1300"
LastUpgradeVersion = "1430"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"

View File

@ -76,5 +76,11 @@
<false/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>This app needs camera access to scan QR codes</string>
<key>io.flutter.embedded_views_preview</key>
<true/>
<key>FLTEnableWideGamut</key>
<false/>
</dict>
</plist>

View File

@ -52,14 +52,14 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
state.copyWith(
isLoggedIn: true,
user: user,
status: AuthStatus.loaded,
status: Status.success,
),
);
} else {
emit(
state.copyWith(
isLoggedIn: false,
status: AuthStatus.loaded,
status: Status.success,
),
);
}
@ -81,7 +81,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
}
Future<void> onLogin(AuthLogin event, Emitter<AuthState> emit) async {
emit(state.copyWith(status: AuthStatus.loading));
emit(state.copyWith(status: Status.inProgress));
final bool successful = await _authRepository.login(
username: event.username,
@ -94,11 +94,11 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
state.copyWith(
user: user ?? User.emptyWithId(event.username),
isLoggedIn: true,
status: AuthStatus.loaded,
status: Status.success,
),
);
} else {
emit(state.copyWith(status: AuthStatus.failure));
emit(state.copyWith(status: Status.failure));
}
}

View File

@ -1,11 +1,5 @@
part of 'auth_bloc.dart';
enum AuthStatus {
loading,
loaded,
failure,
}
class AuthState extends Equatable {
const AuthState({
required this.user,
@ -17,13 +11,13 @@ class AuthState extends Equatable {
const AuthState.init()
: user = const User.empty(),
isLoggedIn = false,
status = AuthStatus.loaded,
status = Status.success,
agreedToEULA = false;
final User user;
final bool isLoggedIn;
final bool agreedToEULA;
final AuthStatus status;
final Status status;
String get username => user.id;
@ -31,7 +25,7 @@ class AuthState extends Equatable {
User? user,
bool? isLoggedIn,
bool? agreedToEULA,
AuthStatus? status,
Status? status,
}) {
return AuthState(
user: user ?? this.user,

View File

@ -79,7 +79,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
const StoriesState.init().copyWith(
isOfflineReading: hasCachedStories &&
// Only go into offline mode in the next session.
state.downloadStatus == StoriesDownloadStatus.initial,
state.downloadStatus == StoriesDownloadStatus.idle,
currentPageSize: pageSize,
downloadStatus: state.downloadStatus,
storiesDownloaded: state.storiesDownloaded,
@ -133,10 +133,12 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
StoriesRefresh event,
Emitter<StoriesState> emit,
) async {
if (state.statusByType[event.type] == Status.inProgress) return;
emit(
state.copyWithStatusUpdated(
type: event.type,
to: StoriesStatus.loading,
to: Status.inProgress,
),
);
@ -144,7 +146,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
emit(
state.copyWithStatusUpdated(
type: event.type,
to: StoriesStatus.loaded,
to: Status.success,
),
);
} else {
@ -157,7 +159,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
emit(
state.copyWithStatusUpdated(
type: event.type,
to: StoriesStatus.loading,
to: Status.inProgress,
),
);
@ -216,7 +218,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
emit(
state.copyWithStatusUpdated(
type: event.type,
to: StoriesStatus.loaded,
to: Status.success,
),
);
}
@ -243,7 +245,7 @@ class StoriesBloc extends Bloc<StoriesEvent, StoriesState> {
void onStoriesLoaded(StoriesLoaded event, Emitter<StoriesState> emit) {
emit(
state.copyWithStatusUpdated(type: event.type, to: StoriesStatus.loaded),
state.copyWithStatusUpdated(type: event.type, to: Status.success),
);
}

View File

@ -1,13 +1,7 @@
part of 'stories_bloc.dart';
enum StoriesStatus {
initial,
loading,
loaded,
}
enum StoriesDownloadStatus {
initial,
idle,
downloading,
finished,
failure,
@ -43,12 +37,12 @@ class StoriesState extends Equatable {
StoryType.ask: <int>[],
StoryType.show: <int>[],
},
this.statusByType = const <StoryType, StoriesStatus>{
StoryType.top: StoriesStatus.initial,
StoryType.best: StoriesStatus.initial,
StoryType.latest: StoriesStatus.initial,
StoryType.ask: StoriesStatus.initial,
StoryType.show: StoriesStatus.initial,
this.statusByType = const <StoryType, Status>{
StoryType.top: Status.idle,
StoryType.best: Status.idle,
StoryType.latest: Status.idle,
StoryType.ask: Status.idle,
StoryType.show: Status.idle,
},
this.currentPageByType = const <StoryType, int>{
StoryType.top: 0,
@ -58,7 +52,7 @@ class StoriesState extends Equatable {
StoryType.show: 0,
},
}) : isOfflineReading = false,
downloadStatus = StoriesDownloadStatus.initial,
downloadStatus = StoriesDownloadStatus.idle,
currentPageSize = 0,
readStoriesIds = const <int>{},
storiesDownloaded = 0,
@ -66,7 +60,7 @@ class StoriesState extends Equatable {
final Map<StoryType, List<Story>> storiesByType;
final Map<StoryType, List<int>> storyIdsByType;
final Map<StoryType, StoriesStatus> statusByType;
final Map<StoryType, Status> statusByType;
final Map<StoryType, int> currentPageByType;
final Set<int> readStoriesIds;
final StoriesDownloadStatus downloadStatus;
@ -78,7 +72,7 @@ class StoriesState extends Equatable {
StoriesState copyWith({
Map<StoryType, List<Story>>? storiesByType,
Map<StoryType, List<int>>? storyIdsByType,
Map<StoryType, StoriesStatus>? statusByType,
Map<StoryType, Status>? statusByType,
Map<StoryType, int>? currentPageByType,
Set<int>? readStoriesIds,
StoriesDownloadStatus? downloadStatus,
@ -133,10 +127,10 @@ class StoriesState extends Equatable {
StoriesState copyWithStatusUpdated({
required StoryType type,
required StoriesStatus to,
required Status to,
}) {
final Map<StoryType, StoriesStatus> newMap =
Map<StoryType, StoriesStatus>.from(statusByType);
final Map<StoryType, Status> newMap =
Map<StoryType, Status>.from(statusByType);
newMap[type] = to;
return copyWith(
statusByType: newMap,
@ -162,9 +156,9 @@ class StoriesState extends Equatable {
final Map<StoryType, List<int>> newStoryIdsMap =
Map<StoryType, List<int>>.from(storyIdsByType);
newStoryIdsMap[type] = <int>[];
final Map<StoryType, StoriesStatus> newStatusMap =
Map<StoryType, StoriesStatus>.from(statusByType);
newStatusMap[type] = StoriesStatus.loading;
final Map<StoryType, Status> newStatusMap =
Map<StoryType, Status>.from(statusByType);
newStatusMap[type] = Status.inProgress;
final Map<StoryType, int> newCurrentPageMap =
Map<StoryType, int>.from(currentPageByType);
newCurrentPageMap[type] = 0;

View File

@ -39,8 +39,9 @@ abstract class Constants {
static const String featureOpenStoryInWebView = 'open_story_in_web_view';
static const String featureLogIn = 'log_in';
static const String featurePinToTop = 'pin_to_top';
static const String featureJumpUpButton = 'jump_up_button';
static const String featureJumpDownButton = 'jump_down_button';
static const String featureJumpUpButton = 'jump_up_button_with_long_press';
static const String featureJumpDownButton =
'jump_down_button_with_long_press';
static final String happyFace = <String>[
'(๑•̀ㅂ•́)و✧',
@ -78,3 +79,15 @@ abstract class RegExpConstants {
static const String linkSuffix = r'(\)|]|,|\*)(.)*$';
static const String number = '[0-9]+';
}
abstract class Durations {
static const Duration ms100 = Duration(milliseconds: 100);
static const Duration ms200 = Duration(milliseconds: 200);
static const Duration ms300 = Duration(milliseconds: 300);
static const Duration ms400 = Duration(milliseconds: 400);
static const Duration ms500 = Duration(milliseconds: 500);
static const Duration ms600 = Duration(milliseconds: 600);
static const Duration oneSecond = Duration(seconds: 1);
static const Duration twoSeconds = Duration(seconds: 2);
static const Duration tenSeconds = Duration(seconds: 10);
}

View File

@ -2,7 +2,7 @@ import 'package:logger/logger.dart';
class CustomLogFilter extends LogFilter {
@override
Level? get level => Level.verbose;
Level? get level => Level.trace;
/// The minimal level allowed in production.
static const Level _minimalLevel = Level.info;

View File

@ -1,49 +1,75 @@
import 'package:flutter/material.dart';
import 'package:hacki/config/constants.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/screens/screens.dart';
/// Custom router.
///
/// Handle named routing.
class CustomRouter {
/// Top level routing.
static Route<dynamic> onGenerateRoute(RouteSettings settings) {
switch (settings.name) {
case HomeScreen.routeName:
return HomeScreen.route();
case ItemScreen.routeName:
return ItemScreen.route(settings.arguments! as ItemScreenArgs);
case SubmitScreen.routeName:
return SubmitScreen.route();
default:
return _errorRoute();
}
}
/// Nested routing for bottom navigation bar.
static Route<dynamic> onGenerateNestedRoute(RouteSettings settings) {
switch (settings.name) {
case ItemScreen.routeName:
return ItemScreen.route(settings.arguments! as ItemScreenArgs);
case SubmitScreen.routeName:
return SubmitScreen.route();
default:
return _errorRoute();
}
}
/// Error route.
static Route<dynamic> _errorRoute() {
return MaterialPageRoute<dynamic>(
settings: const RouteSettings(name: '/error'),
builder: (_) => Scaffold(
appBar: AppBar(
title: const Text('Error'),
),
body: Center(
child: Text(Constants.errorMessage),
final GoRouter router = GoRouter(
observers: <NavigatorObserver>[
RouteObserver<ModalRoute<dynamic>>(),
],
initialLocation: HomeScreen.routeName,
routes: <RouteBase>[
GoRoute(
path: HomeScreen.routeName,
builder: (_, __) => const HomeScreen(),
routes: <RouteBase>[
GoRoute(
path: ItemScreen.routeName,
builder: (_, GoRouterState state) {
final ItemScreenArgs? args = state.extra as ItemScreenArgs?;
if (args == null) {
throw GoError("args can't be null");
}
return ItemScreen.phone(args);
},
),
],
),
GoRoute(
path: '/${ItemScreen.routeName}',
builder: (_, GoRouterState state) {
final ItemScreenArgs? args = state.extra as ItemScreenArgs?;
if (args == null) {
throw GoError("args can't be null");
}
return ItemScreen.phone(args);
},
),
GoRoute(
path: '/${SubmitScreen.routeName}',
builder: (_, __) => BlocProvider<SubmitCubit>(
create: (_) => SubmitCubit(),
child: const SubmitScreen(),
),
);
}
}
),
GoRoute(
path: '/${QrCodeScannerScreen.routeName}',
builder: (_, __) => const QrCodeScannerScreen(),
),
GoRoute(
path: '/${QrCodeViewScreen.routeName}',
builder: (_, GoRouterState state) {
final String? data = state.extra as String?;
if (data == null) {
throw GoError("data can't be null");
}
return QrCodeViewScreen(
data: data,
);
},
),
GoRoute(
path: '/${WebViewScreen.routeName}',
builder: (_, GoRouterState state) {
final String? link = state.extra as String?;
if (link == null) {
throw GoError("link can't be null");
}
return WebViewScreen(
url: link,
);
},
),
],
);

View File

@ -20,7 +20,7 @@ class CustomFileOutput extends LogOutput {
IOSink? _sink;
@override
void init() {
Future<void> init() async {
_sink = file.openWrite(
mode: overrideExisting ? FileMode.writeOnly : FileMode.writeOnlyAppend,
encoding: encoding,

View File

@ -5,9 +5,10 @@ import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/custom_router.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/main.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
import 'package:hacki/screens/screens.dart';
@ -24,15 +25,15 @@ class CommentsCubit extends Cubit<CommentsState> {
CommentsCubit({
required FilterCubit filterCubit,
required CollapseCache collapseCache,
required bool isOfflineReading,
required Item item,
required FetchMode defaultFetchMode,
required CommentsOrder defaultCommentsOrder,
CommentCache? commentCache,
OfflineRepository? offlineRepository,
StoriesRepository? storiesRepository,
SembastRepository? sembastRepository,
Logger? logger,
required bool isOfflineReading,
required Item item,
required FetchMode defaultFetchMode,
required CommentsOrder defaultCommentsOrder,
}) : _filterCubit = filterCubit,
_collapseCache = collapseCache,
_commentCache = commentCache ?? locator.get<CommentCache>(),
@ -105,7 +106,7 @@ class CommentsCubit extends Cubit<CommentsState> {
emit(
state.copyWith(
status: CommentsStatus.loading,
status: CommentsStatus.inProgress,
comments: <Comment>[],
currentPage: 0,
),
@ -131,13 +132,11 @@ class CommentsCubit extends Cubit<CommentsState> {
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
);
break;
case FetchMode.eager:
commentStream = _storiesRepository.fetchAllCommentsRecursivelyStream(
ids: kids,
getFromCache: useCommentCache ? _commentCache.getComment : null,
);
break;
}
}
@ -151,7 +150,7 @@ class CommentsCubit extends Cubit<CommentsState> {
Future<void> refresh() async {
emit(
state.copyWith(
status: CommentsStatus.loading,
status: CommentsStatus.inProgress,
),
);
@ -225,7 +224,7 @@ class CommentsCubit extends Cubit<CommentsState> {
void Function(Comment)? onCommentFetched,
VoidCallback? onDone,
}) {
if (comment == null && state.status == CommentsStatus.loading) return;
if (comment == null && state.status == CommentsStatus.inProgress) return;
switch (state.fetchMode) {
case FetchMode.lazy:
@ -268,30 +267,28 @@ class CommentsCubit extends Cubit<CommentsState> {
});
_streamSubscriptions[comment.id] = streamSubscription;
break;
case FetchMode.eager:
if (_streamSubscription != null) {
emit(state.copyWith(status: CommentsStatus.loading));
emit(state.copyWith(status: CommentsStatus.inProgress));
_streamSubscription
?..resume()
..onData(onCommentFetched);
}
break;
}
}
Future<void> loadParentThread() async {
HapticFeedbackUtil.light();
emit(state.copyWith(fetchParentStatus: CommentsStatus.loading));
emit(state.copyWith(fetchParentStatus: CommentsStatus.inProgress));
final Item? parent =
await _storiesRepository.fetchItem(id: state.item.parent);
if (parent == null) {
return;
} else {
await HackiApp.navigatorKey.currentState?.pushNamed(
ItemScreen.routeName,
arguments: ItemScreenArgs(item: parent),
await router.push(
'/${ItemScreen.routeName}',
extra: ItemScreenArgs(item: parent),
);
emit(
@ -304,7 +301,7 @@ class CommentsCubit extends Cubit<CommentsState> {
Future<void> loadRootThread() async {
HapticFeedbackUtil.light();
emit(state.copyWith(fetchRootStatus: CommentsStatus.loading));
emit(state.copyWith(fetchRootStatus: CommentsStatus.inProgress));
final Story? parent = await _storiesRepository
.fetchParentStory(id: state.item.id)
.then(_toBuildableStory);
@ -312,9 +309,9 @@ class CommentsCubit extends Cubit<CommentsState> {
if (parent == null) {
return;
} else {
await HackiApp.navigatorKey.currentState?.pushNamed(
ItemScreen.routeName,
arguments: ItemScreenArgs(item: parent),
await router.push(
'/${ItemScreen.routeName}',
extra: ItemScreenArgs(item: parent),
);
emit(
@ -352,7 +349,8 @@ class CommentsCubit extends Cubit<CommentsState> {
init(useCommentCache: true);
}
void jump(
/// Scroll to next root level comment.
void scrollToNextRoot(
ItemScrollController itemScrollController,
ItemPositionsListener itemPositionsListener,
) {
@ -382,14 +380,15 @@ class CommentsCubit extends Cubit<CommentsState> {
itemScrollController.scrollTo(
index: i + 1,
alignment: 0.15,
duration: const Duration(milliseconds: 400),
duration: Durations.ms400,
);
return;
}
}
}
void jumpUp(
/// Scroll to previous root level comment.
void scrollToPreviousRoot(
ItemScrollController itemScrollController,
ItemPositionsListener itemPositionsListener,
) {
@ -420,7 +419,7 @@ class CommentsCubit extends Cubit<CommentsState> {
itemScrollController.scrollTo(
index: i + 1,
alignment: 0.15,
duration: const Duration(milliseconds: 400),
duration: Durations.ms400,
);
return;
}

View File

@ -1,11 +1,11 @@
part of 'comments_cubit.dart';
enum CommentsStatus {
init,
loading,
idle,
inProgress,
loaded,
allLoaded,
failure,
error,
}
class CommentsState extends Equatable {
@ -28,9 +28,9 @@ class CommentsState extends Equatable {
required this.fetchMode,
required this.order,
}) : comments = <Comment>[],
status = CommentsStatus.init,
fetchParentStatus = CommentsStatus.init,
fetchRootStatus = CommentsStatus.init,
status = CommentsStatus.idle,
fetchParentStatus = CommentsStatus.idle,
fetchRootStatus = CommentsStatus.idle,
onlyShowTargetComment = false,
currentPage = 0;

View File

@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart';
@ -11,7 +12,7 @@ part 'edit_state.dart';
class EditCubit extends HydratedCubit<EditState> {
EditCubit({DraftCache? draftCache})
: _draftCache = draftCache ?? locator.get<DraftCache>(),
_debouncer = Debouncer(delay: const Duration(seconds: 1)),
_debouncer = Debouncer(delay: Durations.oneSecond),
super(const EditState.init());
final DraftCache _draftCache;

View File

@ -51,7 +51,7 @@ class FavCubit extends Cubit<FavState> {
.onDone(() {
emit(
state.copyWith(
status: FavStatus.loaded,
status: Status.success,
),
);
});
@ -107,7 +107,7 @@ class FavCubit extends Cubit<FavState> {
}
void loadMore() {
emit(state.copyWith(status: FavStatus.loading));
emit(state.copyWith(status: Status.inProgress));
final int currentPage = state.currentPage;
final int len = state.favIds.length;
emit(state.copyWith(currentPage: currentPage + 1));
@ -128,10 +128,10 @@ class FavCubit extends Cubit<FavState> {
)
.listen(_onItemLoaded)
.onDone(() {
emit(state.copyWith(status: FavStatus.loaded));
emit(state.copyWith(status: Status.success));
});
} else {
emit(state.copyWith(status: FavStatus.loaded));
emit(state.copyWith(status: Status.success));
}
}
@ -140,7 +140,7 @@ class FavCubit extends Cubit<FavState> {
emit(
state.copyWith(
status: FavStatus.loading,
status: Status.inProgress,
currentPage: 0,
favItems: <Item>[],
favIds: <int>[],
@ -155,7 +155,7 @@ class FavCubit extends Cubit<FavState> {
)
.listen(_onItemLoaded)
.onDone(() {
emit(state.copyWith(status: FavStatus.loaded));
emit(state.copyWith(status: Status.success));
});
});
}

View File

@ -1,12 +1,5 @@
part of 'fav_cubit.dart';
enum FavStatus {
init,
loading,
loaded,
failure,
}
class FavState extends Equatable {
const FavState({
required this.favIds,
@ -18,18 +11,18 @@ class FavState extends Equatable {
FavState.init()
: favIds = <int>[],
favItems = <Item>[],
status = FavStatus.init,
status = Status.idle,
currentPage = 0;
final List<int> favIds;
final List<Item> favItems;
final FavStatus status;
final Status status;
final int currentPage;
FavState copyWith({
List<int>? favIds,
List<Item>? favItems,
FavStatus? status,
Status? status,
int? currentPage,
}) {
return FavState(

View File

@ -54,7 +54,7 @@ class HistoryCubit extends Cubit<HistoryState> {
}
void loadMore() {
emit(state.copyWith(status: HistoryStatus.loading));
emit(state.copyWith(status: Status.inProgress));
final int currentPage = state.currentPage;
final int len = state.submittedIds.length;
emit(state.copyWith(currentPage: currentPage + 1));
@ -75,10 +75,10 @@ class HistoryCubit extends Cubit<HistoryState> {
)
.listen(_onItemLoaded)
.onDone(() {
emit(state.copyWith(status: HistoryStatus.loaded));
emit(state.copyWith(status: Status.success));
});
} else {
emit(state.copyWith(status: HistoryStatus.loaded));
emit(state.copyWith(status: Status.success));
}
}
@ -86,7 +86,7 @@ class HistoryCubit extends Cubit<HistoryState> {
final String username = _authBloc.state.username;
emit(
state.copyWith(
status: HistoryStatus.loading,
status: Status.inProgress,
currentPage: 0,
submittedIds: <int>[],
submittedItems: <Item>[],
@ -107,7 +107,7 @@ class HistoryCubit extends Cubit<HistoryState> {
)
.listen(_onItemLoaded)
.onDone(() {
emit(state.copyWith(status: HistoryStatus.loaded));
emit(state.copyWith(status: Status.success));
});
}
});

View File

@ -1,12 +1,5 @@
part of 'history_cubit.dart';
enum HistoryStatus {
init,
loading,
loaded,
failure,
}
class HistoryState extends Equatable {
const HistoryState({
required this.submittedIds,
@ -18,18 +11,18 @@ class HistoryState extends Equatable {
HistoryState.init()
: submittedIds = <int>[],
submittedItems = <Item>[],
status = HistoryStatus.init,
status = Status.idle,
currentPage = 0;
final List<int> submittedIds;
final List<Item> submittedItems;
final HistoryStatus status;
final Status status;
final int currentPage;
HistoryState copyWith({
List<int>? submittedIds,
List<Item>? submittedItems,
HistoryStatus? status,
Status? status,
int? currentPage,
}) {
return HistoryState(

View File

@ -4,6 +4,7 @@ import 'dart:math';
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.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/models/models.dart';
@ -31,7 +32,7 @@ class NotificationCubit extends Cubit<NotificationState> {
if (authState.isLoggedIn && authState.username != _username) {
// Get the user setting.
if (_preferenceCubit.state.notificationEnabled) {
Future<void>.delayed(const Duration(seconds: 2), init);
Future<void>.delayed(Durations.twoSeconds, init);
}
// Listen for setting changes in the future.
@ -99,7 +100,7 @@ class NotificationCubit extends Cubit<NotificationState> {
void markAsRead(int id) {
Future.doWhile(() {
if (state.status != NotificationStatus.loading) {
if (state.status != Status.inProgress) {
if (state.unreadCommentsIds.contains(id)) {
final List<int> updatedUnreadIds = <int>[...state.unreadCommentsIds]
..remove(id);
@ -115,7 +116,7 @@ class NotificationCubit extends Cubit<NotificationState> {
void markAllAsRead() {
Future.doWhile(() {
if (state.status != NotificationStatus.loading) {
if (state.status != Status.inProgress) {
emit(state.copyWith(unreadCommentsIds: <int>[]));
_preferenceRepository.updateUnreadCommentsIds(<int>[]);
return false;
@ -130,7 +131,7 @@ class NotificationCubit extends Cubit<NotificationState> {
_preferenceCubit.state.notificationEnabled) {
emit(
state.copyWith(
status: NotificationStatus.loading,
status: Status.inProgress,
),
);
@ -140,14 +141,14 @@ class NotificationCubit extends Cubit<NotificationState> {
} else {
emit(
state.copyWith(
status: NotificationStatus.loaded,
status: Status.success,
),
);
}
}
Future<void> loadMore() async {
emit(state.copyWith(status: NotificationStatus.loading));
emit(state.copyWith(status: Status.inProgress));
final int currentPage = state.currentPage + 1;
final int lower = currentPage * _pageSize + state.offset;
@ -168,7 +169,7 @@ class NotificationCubit extends Cubit<NotificationState> {
emit(
state.copyWith(
status: NotificationStatus.loaded,
status: Status.success,
currentPage: currentPage,
),
);
@ -236,7 +237,7 @@ class NotificationCubit extends Cubit<NotificationState> {
}
}).whenComplete(
() => emit(
state.copyWith(status: NotificationStatus.loaded),
state.copyWith(status: Status.success),
),
);
}

View File

@ -1,12 +1,5 @@
part of 'notification_cubit.dart';
enum NotificationStatus {
initial,
loading,
loaded,
failure,
}
class NotificationState extends Equatable {
const NotificationState({
required this.comments,
@ -23,14 +16,14 @@ class NotificationState extends Equatable {
allCommentsIds = <int>[],
currentPage = 0,
offset = 0,
status = NotificationStatus.initial;
status = Status.idle;
final List<Comment> comments;
final List<int> allCommentsIds;
final List<int> unreadCommentsIds;
final int currentPage;
final int offset;
final NotificationStatus status;
final Status status;
NotificationState copyWith({
List<Comment>? comments,
@ -38,7 +31,7 @@ class NotificationState extends Equatable {
List<int>? unreadCommentsIds,
int? currentPage,
int? offset,
NotificationStatus? status,
Status? status,
}) {
return NotificationState(
comments: comments ?? this.comments,

View File

@ -27,7 +27,7 @@ class PinCubit extends Cubit<PinState> {
emit(state.copyWith(pinnedStoriesIds: ids));
_storiesRepository.fetchStoriesStream(ids: ids).listen(_onStoryFetched);
});
}).whenComplete(() => emit(state.copyWith(status: Status.success)));
}
void pinStory(Story story) {
@ -52,7 +52,10 @@ class PinCubit extends Cubit<PinState> {
_preferenceRepository.updatePinnedStoriesIds(state.pinnedStoriesIds);
}
void refresh() => init();
void refresh() {
if (state.status.isLoading) return;
init();
}
void _onStoryFetched(Story story) {
emit(state.copyWith(pinnedStories: <Story>[...state.pinnedStories, story]));

View File

@ -4,22 +4,27 @@ class PinState extends Equatable {
const PinState({
required this.pinnedStoriesIds,
required this.pinnedStories,
required this.status,
});
PinState.init()
: pinnedStoriesIds = <int>[],
pinnedStories = <Story>[];
pinnedStories = <Story>[],
status = Status.idle;
final List<int> pinnedStoriesIds;
final List<Story> pinnedStories;
final Status status;
PinState copyWith({
List<int>? pinnedStoriesIds,
List<Story>? pinnedStories,
Status? status,
}) {
return PinState(
pinnedStoriesIds: pinnedStoriesIds ?? this.pinnedStoriesIds,
pinnedStories: pinnedStories ?? this.pinnedStories,
status: status ?? this.status,
);
}
@ -27,5 +32,6 @@ class PinState extends Equatable {
List<Object?> get props => <Object?>[
pinnedStoriesIds,
pinnedStories,
status,
];
}

View File

@ -27,7 +27,7 @@ class PollCubit extends Cubit<PollState> {
emit(PollState.init());
}
emit(state.copyWith(status: PollStatus.loading));
emit(state.copyWith(status: Status.inProgress));
List<int> pollOptionsIds = _story.parts;
@ -42,7 +42,7 @@ class PollCubit extends Cubit<PollState> {
// If pollOptionsIds is still empty, exit loading state.
if (pollOptionsIds.isEmpty) {
emit(state.copyWith(status: PollStatus.loaded));
emit(state.copyWith(status: Status.success));
return;
}
@ -72,7 +72,7 @@ class PollCubit extends Cubit<PollState> {
);
}
emit(state.copyWith(status: PollStatus.loaded));
emit(state.copyWith(status: Status.success));
}
}

View File

@ -1,12 +1,5 @@
part of 'poll_cubit.dart';
enum PollStatus {
initial,
loading,
loaded,
failure,
}
class PollState extends Equatable {
const PollState({
required this.totalVotes,
@ -19,18 +12,18 @@ class PollState extends Equatable {
: totalVotes = 0,
selections = <int>{},
pollOptions = <PollOption>[],
status = PollStatus.initial;
status = Status.idle;
final int totalVotes;
final Set<int> selections;
final List<PollOption> pollOptions;
final PollStatus status;
final Status status;
PollState copyWith({
int? totalVotes,
Set<int>? selections,
List<PollOption>? pollOptions,
PollStatus? status,
Status? status,
}) {
return PollState(
totalVotes: totalVotes ?? this.totalVotes,

View File

@ -1,6 +1,7 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/repositories.dart';
part 'post_state.dart';
@ -14,34 +15,31 @@ class PostCubit extends Cubit<PostState> {
final PostRepository _postRepository;
Future<void> post({required String text, required int to}) async {
emit(state.copyWith(status: PostStatus.loading));
emit(state.copyWith(status: Status.inProgress));
final bool successful = await _postRepository.comment(
parentId: to,
text: text,
);
// final successful =
// await Future<bool>.delayed(const Duration(seconds: 2), () => true);
if (successful) {
emit(state.copyWith(status: PostStatus.successful));
emit(state.copyWith(status: Status.success));
} else {
emit(state.copyWith(status: PostStatus.failure));
emit(state.copyWith(status: Status.failure));
}
}
Future<void> edit({required String text, required int id}) async {
emit(state.copyWith(status: PostStatus.loading));
emit(state.copyWith(status: Status.inProgress));
final bool successful = await _postRepository.edit(id: id, text: text);
if (successful) {
emit(state.copyWith(status: PostStatus.successful));
emit(state.copyWith(status: Status.success));
} else {
emit(state.copyWith(status: PostStatus.failure));
emit(state.copyWith(status: Status.failure));
}
}
void reset() {
emit(state.copyWith(status: PostStatus.init));
emit(state.copyWith(status: Status.idle));
}
}

View File

@ -1,20 +1,13 @@
part of 'post_cubit.dart';
enum PostStatus {
init,
loading,
successful,
failure,
}
class PostState extends Equatable {
const PostState({required this.status});
const PostState.init() : status = PostStatus.init;
const PostState.init() : status = Status.idle;
final PostStatus status;
final Status status;
PostState copyWith({PostStatus? status}) {
PostState copyWith({Status? status}) {
return PostState(
status: status ?? this.status,
);

View File

@ -67,10 +67,8 @@ class PreferenceCubit extends Cubit<PreferenceState> {
switch (T) {
case int:
_preferenceRepository.setInt(preference.key, value as int);
break;
case bool:
_preferenceRepository.setBool(preference.key, value as bool);
break;
default:
throw UnimplementedError();
}

View File

@ -68,6 +68,8 @@ class PreferenceState extends Equatable {
bool get swipeGestureEnabled => _isOn<SwipeGesturePreference>();
bool get autoScrollEnabled => _isOn<AutoScrollModePreference>();
List<StoryType> get tabs {
final String result =
preferences.singleWhereType<TabOrderPreference>().val.toString();
@ -85,6 +87,9 @@ class PreferenceState extends Equatable {
return tabs;
}
StoryMarkingMode get storyMarkingMode => StoryMarkingMode.values
.elementAt(preferences.singleWhereType<StoryMarkingModePreference>().val);
FetchMode get fetchMode => FetchMode.values
.elementAt(preferences.singleWhereType<FetchModePreference>().val);

View File

@ -1,7 +1,7 @@
part of 'search_cubit.dart';
enum SearchStatus {
initial,
idle,
loading,
loadingMore,
loaded,
@ -15,7 +15,7 @@ class SearchState extends Equatable {
});
SearchState.init()
: status = SearchStatus.initial,
: status = SearchStatus.idle,
results = <Item>[],
params = SearchParams.init();
@ -23,6 +23,12 @@ class SearchState extends Equatable {
final SearchStatus status;
final SearchParams params;
bool get hasDateFilter =>
params.filters.whereType<DateTimeRangeFilter>().isNotEmpty;
DateTimeRangeFilter? get dateFilter =>
params.filters.whereType<DateTimeRangeFilter>().singleOrNull;
SearchState copyWith({
List<Item>? results,
SearchStatus? status,
@ -42,3 +48,11 @@ class SearchState extends Equatable {
params,
];
}
extension SearchStateExtension on SearchState {
bool get showDateRangeShortcutChips {
return hasDateFilter &&
dateFilter?.startTime != null &&
dateFilter?.endTime != null;
}
}

View File

@ -1,6 +1,7 @@
import 'package:bloc/bloc.dart';
import 'package:equatable/equatable.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/post_repository.dart';
part 'submit_state.dart';
@ -25,7 +26,7 @@ class SubmitCubit extends Cubit<SubmitState> {
}
void onSubmitTapped() {
emit(state.copyWith(status: SubmitStatus.submitting));
emit(state.copyWith(status: Status.inProgress));
if (state.title?.isNotEmpty ?? false) {
_postRepository
@ -35,9 +36,9 @@ class SubmitCubit extends Cubit<SubmitState> {
text: state.text,
)
.then((bool successful) {
emit(state.copyWith(status: SubmitStatus.submitted));
emit(state.copyWith(status: Status.success));
}).onError((Object? error, StackTrace stackTrace) {
emit(state.copyWith(status: SubmitStatus.failure));
emit(state.copyWith(status: Status.failure));
});
}
}

View File

@ -1,12 +1,5 @@
part of 'submit_cubit.dart';
enum SubmitStatus {
initial,
submitting,
submitted,
failure,
}
class SubmitState extends Equatable {
const SubmitState({
required this.title,
@ -19,18 +12,18 @@ class SubmitState extends Equatable {
: title = null,
url = null,
text = null,
status = SubmitStatus.initial;
status = Status.idle;
final String? title;
final String? url;
final String? text;
final SubmitStatus status;
final Status status;
SubmitState copyWith({
String? title,
String? url,
String? text,
SubmitStatus? status,
Status? status,
}) {
return SubmitState(
title: title ?? this.title,

View File

@ -15,16 +15,16 @@ class UserCubit extends Cubit<UserState> {
final StoriesRepository _storiesRepository;
void init({required String userId}) {
emit(state.copyWith(status: UserStatus.loading));
emit(state.copyWith(status: Status.inProgress));
_storiesRepository.fetchUser(id: userId).then((User? user) {
emit(
state.copyWith(
user: user ?? User.emptyWithId(userId),
status: UserStatus.loaded,
status: Status.success,
),
);
}).onError((_, __) {
emit(state.copyWith(status: UserStatus.failure));
emit(state.copyWith(status: Status.failure));
return;
});
}

View File

@ -1,12 +1,5 @@
part of 'user_cubit.dart';
enum UserStatus {
initial,
loading,
loaded,
failure,
}
class UserState extends Equatable {
const UserState({
required this.user,
@ -15,14 +8,14 @@ class UserState extends Equatable {
const UserState.init()
: user = const User.empty(),
status = UserStatus.initial;
status = Status.idle;
final User user;
final UserStatus status;
final Status status;
UserState copyWith({
User? user,
UserStatus? status,
Status? status,
}) {
return UserState(
user: user ?? this.user,

View File

@ -6,7 +6,7 @@ enum Vote {
}
enum VoteStatus {
initial,
idle,
canceled,
submitted,
failureBeHumble,
@ -24,7 +24,7 @@ class VoteState extends Equatable {
const VoteState.init({required this.item})
: vote = null,
status = VoteStatus.initial;
status = VoteStatus.idle;
/// Null means user has not voted,
/// True means user voted up,

View File

@ -14,6 +14,10 @@ extension ObjectExtension on Object {
String identifier = '',
StackTrace? stackTrace,
}) {
locator.get<Logger>().e(identifier, this, stackTrace ?? StackTrace.current);
locator.get<Logger>().e(
identifier,
error: this,
stackTrace: stackTrace ?? StackTrace.current,
);
}
}

View File

@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:hacki/blocs/auth/auth_bloc.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/screens/item/models/models.dart';
import 'package:hacki/screens/item/widgets/widgets.dart';
@ -36,9 +36,9 @@ extension StateExtension on State {
if (splitViewEnabled && !forceNewScreen) {
context.read<SplitViewCubit>().updateItemScreenArgs(args);
} else {
return HackiApp.navigatorKey.currentState?.pushNamed(
ItemScreen.routeName,
arguments: args,
context.push(
'/${ItemScreen.routeName}',
extra: args,
);
}
@ -75,16 +75,12 @@ extension StateExtension on State {
break;
case MenuAction.fav:
onFavTapped(item);
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;
}
@ -116,12 +112,11 @@ extension StateExtension on State {
mainAxisSize: MainAxisSize.min,
children: <Widget>[
ListTile(
onTap: () => Navigator.pop(context, item.url),
onTap: () => context.pop(item.url),
title: const Text('Link to article'),
),
ListTile(
onTap: () => Navigator.pop(
context,
onTap: () => context.pop(
'https://news.ycombinator.com/item?id=${item.id}',
),
title: const Text('Link to HN'),
@ -159,13 +154,13 @@ extension StateExtension on State {
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context, false),
onPressed: () => context.pop(false),
child: const Text(
'Cancel',
),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
onPressed: () => context.pop(true),
child: const Text(
'Yes',
),
@ -197,13 +192,13 @@ extension StateExtension on State {
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context, false),
onPressed: () => context.pop(false),
child: const Text(
'Cancel',
),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
onPressed: () => context.pop(true),
child: const Text(
'Yes',
),

View File

@ -17,7 +17,6 @@ import 'package:hacki/config/custom_router.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/services/fetcher.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/theme_util.dart';
@ -58,8 +57,8 @@ Future<void> main({bool testing = false}) async {
FlutterError.onError = (FlutterErrorDetails details) {
locator.get<Logger>().e(
details.summary,
details.exceptionAsString(),
details.stack,
error: details.exceptionAsString(),
stackTrace: details.stack,
);
};
@ -155,19 +154,16 @@ Future<void> main({bool testing = false}) async {
class HackiApp extends StatelessWidget {
const HackiApp({
super.key,
this.savedThemeMode,
required this.trueDarkMode,
required this.font,
super.key,
this.savedThemeMode,
});
final AdaptiveThemeMode? savedThemeMode;
final Font font;
final bool trueDarkMode;
static final GlobalKey<NavigatorState> navigatorKey =
GlobalKey<NavigatorState>();
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
@ -241,7 +237,7 @@ class HackiApp extends StatelessWidget {
create: (BuildContext context) => TabCubit(
preferenceCubit: context.read<PreferenceCubit>(),
)..init(),
)
),
],
child: AdaptiveTheme(
light: ThemeData(
@ -269,8 +265,8 @@ class HackiApp extends StatelessWidget {
AsyncSnapshot<AdaptiveThemeMode?> snapshot,
) {
final AdaptiveThemeMode? mode = snapshot.data;
ThemeUtil.updateAndroidStatusBarSetting(
Theme.of(context).brightness,
ThemeUtil.updateStatusBarSetting(
SchedulerBinding.instance.platformDispatcher.platformBrightness,
mode,
);
return BlocBuilder<PreferenceCubit, PreferenceState>(
@ -281,20 +277,18 @@ class HackiApp extends StatelessWidget {
final bool useTrueDark = prefState.trueDarkEnabled &&
(mode == AdaptiveThemeMode.dark ||
(mode == AdaptiveThemeMode.system &&
SchedulerBinding
.instance.window.platformBrightness ==
View.of(context)
.platformDispatcher
.platformBrightness ==
Brightness.dark));
return FeatureDiscovery(
child: MaterialApp(
child: MaterialApp.router(
title: 'Hacki',
debugShowCheckedModeBanner: false,
theme: useTrueDark ? trueDarkTheme : theme,
navigatorKey: navigatorKey,
navigatorObservers: <NavigatorObserver>[
locator.get<RouteObserver<ModalRoute<dynamic>>>(),
],
onGenerateRoute: CustomRouter.onGenerateRoute,
initialRoute: HomeScreen.routeName,
theme: (useTrueDark ? trueDarkTheme : theme).copyWith(
useMaterial3: false,
),
routerConfig: router,
),
);
},

View File

@ -0,0 +1,14 @@
import 'package:flutter/material.dart' show IconData, Icons;
enum ExportDestination {
qrCode('QR code', icon: Icons.qr_code),
clipBoard('ClipBoard', icon: Icons.copy);
const ExportDestination(
this.label, {
required this.icon,
});
final String label;
final IconData icon;
}

View File

@ -4,7 +4,8 @@ enum FontSize {
small('Small', TextDimens.pt15),
regular('Regular', TextDimens.pt16),
large('Large', TextDimens.pt17),
xlarge('XLarge', TextDimens.pt18);
xlarge('XLarge', TextDimens.pt18),
xxlarge('XXLarge', TextDimens.pt19);
const FontSize(this.description, this.fontSize);

View File

@ -35,6 +35,27 @@ class BuildableComment extends Comment with Buildable {
hidden: comment.hidden,
);
@override
BuildableComment copyWith({
int? level,
bool? hidden,
}) {
return BuildableComment(
id: id,
time: time,
parent: parent,
score: score,
by: by,
text: text,
kids: kids,
dead: dead,
deleted: deleted,
hidden: hidden ?? this.hidden,
level: level ?? this.level,
elements: elements,
);
}
@override
final List<LinkifyElement> elements;
}

View File

@ -1,4 +1,5 @@
export 'comments_order.dart';
export 'export_destination.dart';
export 'fetch_mode.dart';
export 'font.dart';
export 'font_size.dart';
@ -6,5 +7,7 @@ export 'item/item.dart';
export 'post_data.dart';
export 'preference.dart';
export 'search_params.dart';
export 'status.dart';
export 'story_marking_mode.dart';
export 'story_type.dart';
export 'user.dart';

View File

@ -23,16 +23,18 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
FontPreference(),
FontSizePreference(),
TabOrderPreference(),
StoryMarkingModePreference(),
// Order of items below matters and
// reflects the order on settings screen.
const DisplayModePreference(),
const MetadataModePreference(),
const StoryUrlModePreference(),
const MarkReadStoriesModePreference(),
const NotificationModePreference(),
const SwipeGesturePreference(),
const AutoScrollModePreference(),
const CollapseModePreference(),
const ReaderModePreference(),
const MarkReadStoriesModePreference(),
const EyeCandyModePreference(),
const TrueDarkModePreference(),
],
@ -54,18 +56,21 @@ const bool _notificationModeDefaultValue = true;
const bool _swipeGestureModeDefaultValue = false;
const bool _displayModeDefaultValue = true;
const bool _eyeCandyModeDefaultValue = false;
const bool _trueDarkModeDefaultValue = false;
const bool _trueDarkModeDefaultValue = true;
const bool _readerModeDefaultValue = true;
const bool _markReadStoriesModeDefaultValue = true;
const bool _metadataModeDefaultValue = true;
const bool _storyUrlModeDefaultValue = true;
const bool _collapseModeDefaultValue = true;
const bool _autoScrollModeDefaultValue = true;
final int _fetchModeDefaultValue = FetchMode.eager.index;
final int _commentsOrderDefaultValue = CommentsOrder.natural.index;
final int _fontSizeDefaultValue = FontSize.regular.index;
final int _fontDefaultValue = Font.roboto.index;
final int _tabOrderDefaultValue =
StoryType.convertToSettingsValue(StoryType.values);
final int _markStoriesAsReadWhenPreferenceDefaultValue =
StoryMarkingMode.tap.index;
class SwipeGesturePreference extends BooleanPreference {
const SwipeGesturePreference({bool? val})
@ -127,6 +132,26 @@ class CollapseModePreference extends BooleanPreference {
'''if disabled, tap on the top of comment tile to collapse.''';
}
class AutoScrollModePreference extends BooleanPreference {
const AutoScrollModePreference({bool? val})
: super(val: val ?? _autoScrollModeDefaultValue);
@override
AutoScrollModePreference copyWith({required bool? val}) {
return AutoScrollModePreference(val: val);
}
@override
String get key => 'autoScrollMode';
@override
String get title => 'Auto-scroll on collapsing';
@override
String get subtitle =>
'''automatically scroll to next comment when you collapse a comment.''';
}
/// The value deciding whether or not the story
/// tile should display link preview. Defaults to true.
class DisplayModePreference extends BooleanPreference {
@ -342,3 +367,19 @@ class TabOrderPreference extends IntPreference {
@override
String get title => 'Tab order';
}
class StoryMarkingModePreference extends IntPreference {
StoryMarkingModePreference({int? val})
: super(val: val ?? _markStoriesAsReadWhenPreferenceDefaultValue);
@override
StoryMarkingModePreference copyWith({required int? val}) {
return StoryMarkingModePreference(val: val);
}
@override
String get key => 'storyMarkingMode';
@override
String get title => 'Mark a Story as Read on';
}

View File

@ -30,14 +30,27 @@ class DateTimeRangeFilter implements NumericFilter {
@override
String get query {
if (startTime == null || endTime == null) return '';
final int? startTimestamp = startTime == null
? null
: startTime!.toUtc().millisecondsSinceEpoch ~/ 1000;
final int? endTimestamp = endTime == null
int? endTimestamp = endTime == null
? null
: endTime!.toUtc().millisecondsSinceEpoch ~/ 1000;
if (startTimestamp == endTimestamp) {
endTimestamp = startTime!
.add(const Duration(hours: 24))
.toUtc()
.millisecondsSinceEpoch ~/
1000;
}
if (startTimestamp == null || endTimestamp == null) return '';
final String query =
'''${startTimestamp == null ? '' : 'created_at_i>$startTimestamp'},${endTimestamp == null ? '' : 'created_at_i<$endTimestamp'}''';
'''created_at_i>=$startTimestamp, created_at_i<=$endTimestamp''';
if (query.endsWith(',')) {
return query.replaceFirst(',', '');

14
lib/models/status.dart Normal file
View File

@ -0,0 +1,14 @@
enum Status {
idle,
inProgress,
success,
failure,
}
extension StatusExtension on Status {
bool get isLoading => this == Status.inProgress;
bool get isSuccessful => this == Status.success;
bool get hasError => this == Status.failure;
}

View File

@ -0,0 +1,21 @@
/// Used for determining when to mark a story as read.
enum StoryMarkingMode {
// Mark a story as read after user scrolls past it.
scrollPast('scrolling past'),
// Mark a story as read after user taps on it.
tap('tapping'),
// Mark a story as read after user scrolls past or taps on it, whichever
// happens the first.
scrollPastOrTap('scrolling past or tapping');
const StoryMarkingMode(this.label);
final String label;
bool get shouldDetectScrollingPast =>
this == StoryMarkingMode.scrollPast ||
this == StoryMarkingMode.scrollPastOrTap;
bool get shouldDetectTapping =>
this == StoryMarkingMode.tap || this == StoryMarkingMode.scrollPastOrTap;
}

View File

@ -64,7 +64,7 @@ class PostableRepository {
validateStatus: validateStatus,
),
);
} on DioError catch (e) {
} on DioException catch (e) {
throw ServiceException(e.message);
}
}

View File

@ -2,7 +2,6 @@ import 'package:flutter/foundation.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/services/services.dart';
import 'package:hacki/utils/utils.dart';
import 'package:tuple/tuple.dart';
/// [StoriesRepository] is for fetching
/// [Item] such as [Story], [PollOption], [Comment] or [User].
@ -187,7 +186,7 @@ class StoriesRepository {
/// Fetch the parent [Story] of a [Comment] as well as
/// the list of [Comment] traversed in order to reach the parent.
Future<Tuple2<Story, List<Comment>>?> fetchParentStoryWithComments({
Future<(Story, List<Comment>)?> fetchParentStoryWithComments({
required int id,
}) async {
Item? item;
@ -206,7 +205,7 @@ class StoriesRepository {
parentComments[i].copyWith(level: parentComments.length - i - 1);
}
return Tuple2<Story, List<Comment>>(
return (
item as Story,
parentComments.reversed.toList(),
);

View File

@ -7,6 +7,7 @@ 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:go_router/go_router.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart';
@ -30,13 +31,6 @@ class HomeScreen extends StatefulWidget {
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();
}
@ -57,7 +51,7 @@ class _HomeScreenState extends State<HomeScreen>
DeviceScreenType.mobile) {
locator.get<Logger>().i('resetting comments in CommentCache');
Future<void>.delayed(
const Duration(milliseconds: 500),
Durations.ms500,
locator.get<CommentCache>().resetComments,
);
}
@ -209,11 +203,13 @@ class _HomeScreenState extends State<HomeScreen>
);
}
void onStoryTapped(Story story, {bool isPin = false}) {
void onStoryTapped(Story story) {
final bool useReader = context.read<PreferenceCubit>().state.readerEnabled;
final bool offlineReading =
context.read<StoriesBloc>().state.isOfflineReading;
final bool splitViewEnabled = context.read<SplitViewCubit>().state.enabled;
final StoryMarkingMode storyMarkingMode =
context.read<PreferenceCubit>().state.storyMarkingMode;
// 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.
@ -222,23 +218,14 @@ class _HomeScreenState extends State<HomeScreen>
if (isJobWithLink) {
context.read<ReminderCubit>().removeLastReadStoryId();
} else {
final ItemScreenArgs args = ItemScreenArgs(
item: story,
);
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();
});
context.push('/${ItemScreen.routeName}', extra: args);
}
}
@ -250,11 +237,9 @@ class _HomeScreenState extends State<HomeScreen>
);
}
context.read<StoriesBloc>().add(
StoryRead(
story: story,
),
);
if (storyMarkingMode.shouldDetectTapping) {
context.read<StoriesBloc>().add(StoryRead(story: story));
}
if (Platform.isIOS) {
FlutterSiriSuggestions.instance.registerActivity(

View File

@ -6,8 +6,8 @@ import 'package:hacki/styles/styles.dart';
class MobileHomeScreen extends StatelessWidget {
const MobileHomeScreen({
super.key,
required this.homeScreen,
super.key,
});
final Widget homeScreen;

View File

@ -10,13 +10,13 @@ import 'package:hacki/utils/utils.dart';
class PinnedStories extends StatelessWidget {
const PinnedStories({
super.key,
required this.preferenceState,
required this.onStoryTapped,
super.key,
});
final PreferenceState preferenceState;
final void Function(Story story, {bool isPin}) onStoryTapped;
final void Function(Story story) onStoryTapped;
@override
Widget build(BuildContext context) {
@ -49,7 +49,7 @@ class PinnedStories extends StatelessWidget {
child: StoryTile(
key: ValueKey<String>('${story.id}-PinnedStoryTile'),
story: story,
onTap: () => onStoryTapped(story, isPin: true),
onTap: () => onStoryTapped(story),
showWebPreview: preferenceState.complexStoryTileEnabled,
showMetadata: preferenceState.metadataEnabled,
showUrl: preferenceState.urlEnabled,

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart' hide Badge;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/screens/widgets/widgets.dart';
@ -8,8 +9,8 @@ import 'package:responsive_builder/responsive_builder.dart';
class TabletHomeScreen extends StatelessWidget {
const TabletHomeScreen({
super.key,
required this.homeScreen,
super.key,
});
final Widget homeScreen;
@ -36,7 +37,7 @@ class TabletHomeScreen extends StatelessWidget {
top: Dimens.zero,
bottom: Dimens.zero,
width: homeScreenWidth,
duration: const Duration(milliseconds: 300),
duration: Durations.ms300,
curve: Curves.elasticOut,
child: homeScreen,
),
@ -52,7 +53,7 @@ class TabletHomeScreen extends StatelessWidget {
top: Dimens.zero,
bottom: Dimens.zero,
left: state.expanded ? Dimens.zero : homeScreenWidth,
duration: const Duration(milliseconds: 300),
duration: Durations.ms300,
curve: Curves.elasticOut,
child: const _TabletStoryView(),
),
@ -75,7 +76,7 @@ class _TabletStoryView extends StatelessWidget {
previous.itemScreenArgs != current.itemScreenArgs,
builder: (BuildContext context, SplitViewState state) {
if (state.itemScreenArgs != null) {
return ItemScreen.build(context, state.itemScreenArgs!);
return ItemScreen.tablet(context, state.itemScreenArgs!);
}
return Material(

View File

@ -3,6 +3,7 @@ import 'package:feature_discovery/feature_discovery.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/locator.dart';
@ -45,50 +46,45 @@ class ItemScreenArgs extends Equatable {
class ItemScreen extends StatefulWidget {
const ItemScreen({
super.key,
this.splitViewEnabled = false,
required this.item,
required this.parentComments,
super.key,
this.splitViewEnabled = false,
});
static const String routeName = '/item';
static const String routeName = 'item';
static Route<dynamic> route(ItemScreenArgs args) {
return MaterialPageRoute<ItemScreen>(
settings: const RouteSettings(name: routeName),
builder: (BuildContext context) => RepositoryProvider<CollapseCache>(
create: (_) => CollapseCache(),
lazy: false,
child: MultiBlocProvider(
providers: <BlocProvider<dynamic>>[
BlocProvider<CommentsCubit>(
create: (BuildContext context) => CommentsCubit(
filterCubit: context.read<FilterCubit>(),
isOfflineReading:
context.read<StoriesBloc>().state.isOfflineReading,
item: args.item,
collapseCache: context.read<CollapseCache>(),
defaultFetchMode:
context.read<PreferenceCubit>().state.fetchMode,
defaultCommentsOrder:
context.read<PreferenceCubit>().state.order,
)..init(
onlyShowTargetComment: args.onlyShowTargetComment,
targetAncestors: args.targetComments,
useCommentCache: args.useCommentCache,
),
),
],
child: ItemScreen(
item: args.item,
parentComments: args.targetComments ?? <Comment>[],
static Widget phone(ItemScreenArgs args) {
return RepositoryProvider<CollapseCache>(
create: (_) => CollapseCache(),
lazy: false,
child: MultiBlocProvider(
providers: <BlocProvider<dynamic>>[
BlocProvider<CommentsCubit>(
create: (BuildContext context) => CommentsCubit(
filterCubit: context.read<FilterCubit>(),
isOfflineReading:
context.read<StoriesBloc>().state.isOfflineReading,
item: args.item,
collapseCache: context.read<CollapseCache>(),
defaultFetchMode: context.read<PreferenceCubit>().state.fetchMode,
defaultCommentsOrder: context.read<PreferenceCubit>().state.order,
)..init(
onlyShowTargetComment: args.onlyShowTargetComment,
targetAncestors: args.targetComments,
useCommentCache: args.useCommentCache,
),
),
],
child: ItemScreen(
item: args.item,
parentComments: args.targetComments ?? <Comment>[],
),
),
);
}
static Widget build(BuildContext context, ItemScreenArgs args) {
static Widget tablet(BuildContext context, ItemScreenArgs args) {
return WillPopScope(
onWillPop: () async {
if (context.read<SplitViewCubit>().state.expanded) {
@ -153,9 +149,9 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
);
final GlobalKey fontSizeIconButtonKey = GlobalKey();
static const Duration _storyLinkTapThrottleDelay = Duration(seconds: 2);
static const Duration _storyLinkTapThrottleDelay = Durations.twoSeconds;
static const Duration _featureDiscoveryDismissThrottleDelay =
Duration(seconds: 1);
Durations.oneSecond;
@override
void didPop() {
@ -168,7 +164,6 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
@override
void initState() {
super.initState();
SchedulerBinding.instance
..addPostFrameCallback((_) {
FeatureDiscovery.discoverFeatures(
@ -213,12 +208,8 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
listeners: <BlocListener<dynamic, dynamic>>[
BlocListener<PostCubit, PostState>(
listener: (BuildContext context, PostState postState) {
if (postState.status == PostStatus.successful) {
Navigator.popUntil(
context,
(Route<dynamic> route) =>
route.settings.name == ItemScreen.routeName,
);
if (postState.status == Status.success) {
context.pop();
final String verb =
context.read<EditCubit>().state.replyingTo == null
? 'updated'
@ -228,12 +219,8 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
showSnackBar(content: msg);
context.read<EditCubit>().onReplySubmittedSuccessfully();
context.read<PostCubit>().reset();
} else if (postState.status == PostStatus.failure) {
Navigator.popUntil(
context,
(Route<dynamic> route) =>
route.settings.name == ItemScreen.routeName,
);
} else if (postState.status == Status.failure) {
context.pop();
showErrorSnackBar();
context.read<PostCubit>().reset();
}
@ -355,6 +342,7 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
enableDrag: false,
builder: (BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
@ -370,7 +358,7 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
),
SizedBox(
height: MediaQuery.of(context).viewInsets.bottom,
)
),
],
);
},
@ -444,7 +432,7 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
leading: const Icon(Icons.av_timer),
title: const Text('View ancestors'),
onTap: () {
Navigator.pop(context);
context.pop();
onTimeMachineActivated(comment);
},
enabled:
@ -455,8 +443,7 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
title: const Text('View in separate thread'),
onTap: () {
locator.get<AppReviewService>().requestReview();
Navigator.pop(context);
context.pop();
goToItemScreen(
args: ItemScreenArgs(
item: comment,

View File

@ -7,11 +7,11 @@ import 'package:hacki/utils/utils.dart';
class CustomAppBar extends AppBar {
CustomAppBar({
super.key,
required Item item,
required Color super.backgroundColor,
required super.backgroundColor,
required VoidCallback onFontSizeTap,
required GlobalKey fontSizeIconButtonKey,
super.key,
bool splitViewEnabled = false,
VoidCallback? onZoomTap,
bool? expanded,
@ -43,6 +43,7 @@ class CustomAppBar extends AppBar {
fontFamily: FeatherIcons.type.fontFamily,
package: FeatherIcons.type.fontPackage,
),
textScaleFactor: 1,
),
onPressed: onFontSizeTap,
),

View File

@ -10,9 +10,9 @@ import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class CustomFloatingActionButton extends StatelessWidget {
const CustomFloatingActionButton({
super.key,
required this.itemScrollController,
required this.itemPositionsListener,
super.key,
});
final ItemScrollController itemScrollController;
@ -32,30 +32,32 @@ class CustomFloatingActionButton extends StatelessWidget {
Icons.keyboard_arrow_up,
color: Palette.white,
),
title: const Text('Jump to previous root level comment.'),
title: const Text('Shortcut'),
description: const Text(
'''Tapping on this button will take you to the previous off-screen root level comment.''',
'''Tapping on this button will take you to the previous off-screen root level comment.\n\nLong press on it to jump to the very beginning of this thread.''',
),
child: FloatingActionButton.small(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
child: InkWell(
onLongPress: () => itemScrollController.scrollTo(
index: 0,
duration: Durations.ms400,
),
child: FloatingActionButton.small(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
/// Randomly generated string as heroTag to prevent
/// default [FloatingActionButton] animation.
heroTag: UniqueKey().hashCode,
onPressed: () {
if (state.status == CommentsStatus.loading) return;
HapticFeedbackUtil.selection();
context.read<CommentsCubit>().jumpUp(
itemScrollController,
itemPositionsListener,
);
},
child: Icon(
Icons.keyboard_arrow_up,
color: state.status == CommentsStatus.loading
? Palette.grey
: Theme.of(context).colorScheme.primary,
/// Randomly generated string as heroTag to prevent
/// default [FloatingActionButton] animation.
heroTag: UniqueKey().hashCode,
onPressed: () {
HapticFeedbackUtil.selection();
context.read<CommentsCubit>().scrollToPreviousRoot(
itemScrollController,
itemPositionsListener,
);
},
child: Icon(
Icons.keyboard_arrow_up,
color: Theme.of(context).colorScheme.primary,
),
),
),
),
@ -65,29 +67,31 @@ class CustomFloatingActionButton extends StatelessWidget {
Icons.keyboard_arrow_down,
color: Palette.white,
),
title: const Text('Jump to next root level comment.'),
title: const Text('Shortcut'),
description: const Text(
'''Tapping on this button will take you to the next off-screen root level comment.''',
'''Tapping on this button will take you to the next off-screen root level comment.\n\nLong press on it to jump to the end of this thread.''',
),
child: FloatingActionButton.small(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
child: InkWell(
onLongPress: () => itemScrollController.scrollTo(
index: state.comments.length,
duration: Durations.ms400,
),
child: FloatingActionButton.small(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
/// Same as above.
heroTag: UniqueKey().hashCode,
onPressed: () {
if (state.status == CommentsStatus.loading) return;
HapticFeedbackUtil.selection();
context.read<CommentsCubit>().jump(
itemScrollController,
itemPositionsListener,
);
},
child: Icon(
Icons.keyboard_arrow_down,
color: state.status == CommentsStatus.loading
? Palette.grey
: Theme.of(context).colorScheme.primary,
/// Same as above.
heroTag: UniqueKey().hashCode,
onPressed: () {
HapticFeedbackUtil.selection();
context.read<CommentsCubit>().scrollToNextRoot(
itemScrollController,
itemPositionsListener,
);
},
child: Icon(
Icons.keyboard_arrow_down,
color: Theme.of(context).colorScheme.primary,
),
),
),
),

View File

@ -8,8 +8,8 @@ import 'package:hacki/utils/utils.dart';
class FavIconButton extends StatelessWidget {
const FavIconButton({
super.key,
required this.storyId,
super.key,
});
final int storyId;

View File

@ -6,8 +6,8 @@ import 'package:hacki/utils/utils.dart';
class LinkIconButton extends StatelessWidget {
const LinkIconButton({
super.key,
required this.storyId,
super.key,
});
final int storyId;

View File

@ -1,8 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/status.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
@ -23,7 +25,7 @@ class _LoginDialogState extends State<LoginDialog> {
return BlocConsumer<AuthBloc, AuthState>(
listener: (BuildContext context, AuthState state) {
if (state.isLoggedIn) {
Navigator.pop(context);
context.pop();
showSnackBar(
content: 'Logged in successfully! ${Constants.happyFace}',
);
@ -32,7 +34,7 @@ class _LoginDialogState extends State<LoginDialog> {
builder: (BuildContext context, AuthState state) {
return SimpleDialog(
children: <Widget>[
if (state.status == AuthStatus.loading)
if (state.status.isLoading)
const SizedBox(
height: Dimens.pt36,
width: Dimens.pt36,
@ -82,7 +84,7 @@ class _LoginDialogState extends State<LoginDialog> {
const SizedBox(
height: Dimens.pt16,
),
if (state.status == AuthStatus.failure)
if (state.status == Status.failure)
Padding(
padding: const EdgeInsets.only(
left: Dimens.pt18,
@ -141,7 +143,7 @@ class _LoginDialogState extends State<LoginDialog> {
),
],
),
)
),
],
),
Padding(
@ -152,7 +154,7 @@ class _LoginDialogState extends State<LoginDialog> {
children: <Widget>[
TextButton(
onPressed: () {
Navigator.pop(context);
context.pop();
context.read<AuthBloc>().add(AuthInitialize());
},
child: const Text(

View File

@ -17,7 +17,6 @@ import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class MainView extends StatelessWidget {
const MainView({
super.key,
required this.itemScrollController,
required this.itemPositionsListener,
required this.commentEditingController,
@ -27,6 +26,7 @@ class MainView extends StatelessWidget {
required this.onMoreTapped,
required this.onRightMoreTapped,
required this.onReplyTapped,
super.key,
});
final ItemScrollController itemScrollController;
@ -139,6 +139,7 @@ class MainView extends StatelessWidget {
},
onMoreTapped: onMoreTapped,
onRightMoreTapped: onRightMoreTapped,
itemScrollController: itemScrollController,
),
);
},
@ -158,7 +159,7 @@ class MainView extends StatelessWidget {
prev.status != current.status,
builder: (BuildContext context, CommentsState state) {
return AnimatedOpacity(
opacity: state.status == CommentsStatus.loading
opacity: state.status == CommentsStatus.inProgress
? NumSwitch.on
: NumSwitch.off,
duration: const Duration(
@ -210,161 +211,170 @@ class _ParentItemSection extends StatelessWidget {
padding: EdgeInsets.only(bottom: Dimens.pt6),
child: OfflineBanner(),
),
Slidable(
startActionPane: ActionPane(
motion: const BehindMotion(),
children: <Widget>[
SlidableAction(
onPressed: (_) {
HapticFeedbackUtil.light();
DeviceGestureWrapper(
child: Slidable(
startActionPane: ActionPane(
motion: const BehindMotion(),
children: <Widget>[
SlidableAction(
onPressed: (_) {
HapticFeedbackUtil.light();
if (state.item.id !=
context.read<EditCubit>().state.replyingTo?.id) {
commentEditingController.clear();
}
context.read<EditCubit>().onReplyTapped(state.item);
if (state.item.id !=
context.read<EditCubit>().state.replyingTo?.id) {
commentEditingController.clear();
}
context.read<EditCubit>().onReplyTapped(state.item);
onReplyTapped();
},
backgroundColor: Palette.orange,
foregroundColor: Palette.white,
icon: Icons.message,
),
SlidableAction(
onPressed: (BuildContext context) =>
onMoreTapped(state.item, context.rect),
backgroundColor: Palette.orange,
foregroundColor: Palette.white,
icon: Icons.more_horiz,
),
],
),
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(
left: Dimens.pt6,
right: Dimens.pt6,
onReplyTapped();
},
backgroundColor: Palette.orange,
foregroundColor: Palette.white,
icon: Icons.message,
),
child: Row(
children: <Widget>[
Text(
state.item.by,
style: const TextStyle(
color: Palette.orange,
),
),
const Spacer(),
Text(
state.item.timeAgo,
style: const TextStyle(
color: Palette.grey,
),
),
],
SlidableAction(
onPressed: (BuildContext context) =>
onMoreTapped(state.item, context.rect),
backgroundColor: Palette.orange,
foregroundColor: Palette.white,
icon: Icons.more_horiz,
),
),
BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (
PreferenceState previous,
PreferenceState current,
) =>
previous.fontSize != current.fontSize,
builder: (
BuildContext context,
PreferenceState prefState,
) {
return Column(
],
),
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(
left: Dimens.pt6,
right: Dimens.pt6,
),
child: Row(
children: <Widget>[
if (state.item is Story)
InkWell(
onTap: () => LinkUtil.launch(
state.item.url,
useReader: context
.read<PreferenceCubit>()
.state
.readerEnabled,
offlineReading: context
.read<StoriesBloc>()
.state
.isOfflineReading,
),
child: Padding(
padding: const EdgeInsets.only(
left: Dimens.pt6,
right: Dimens.pt6,
bottom: Dimens.pt12,
top: Dimens.pt12,
Text(
state.item.by,
style: const TextStyle(
color: Palette.orange,
),
textScaleFactor:
MediaQuery.of(context).textScaleFactor,
),
const Spacer(),
Text(
state.item.timeAgo,
style: const TextStyle(
color: Palette.grey,
),
textScaleFactor:
MediaQuery.of(context).textScaleFactor,
),
],
),
),
BlocBuilder<PreferenceCubit, PreferenceState>(
buildWhen: (
PreferenceState previous,
PreferenceState current,
) =>
previous.fontSize != current.fontSize,
builder: (
BuildContext context,
PreferenceState prefState,
) {
return Column(
children: <Widget>[
if (state.item is Story)
InkWell(
onTap: () => LinkUtil.launch(
state.item.url,
useReader: context
.read<PreferenceCubit>()
.state
.readerEnabled,
offlineReading: context
.read<StoriesBloc>()
.state
.isOfflineReading,
),
child: Text.rich(
TextSpan(
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: prefState.fontSize.fontSize,
color: Theme.of(context)
.textTheme
.bodyLarge
?.color,
),
children: <TextSpan>[
TextSpan(
semanticsLabel: state.item.title,
text: state.item.title,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: prefState.fontSize.fontSize,
color: state.item.url.isNotEmpty
? Palette.orange
: null,
),
child: Padding(
padding: const EdgeInsets.only(
left: Dimens.pt6,
right: Dimens.pt6,
bottom: Dimens.pt12,
top: Dimens.pt12,
),
child: Text.rich(
TextSpan(
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: prefState.fontSize.fontSize,
color: Theme.of(context)
.textTheme
.bodyLarge
?.color,
),
if (state.item.url.isNotEmpty)
children: <TextSpan>[
TextSpan(
text:
''' (${(state.item as Story).readableUrl})''',
semanticsLabel: state.item.title,
text: state.item.title,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize:
prefState.fontSize.fontSize - 4,
color: Palette.orange,
fontSize: prefState.fontSize.fontSize,
color: state.item.url.isNotEmpty
? Palette.orange
: null,
),
),
],
if (state.item.url.isNotEmpty)
TextSpan(
text:
''' (${(state.item as Story).readableUrl})''',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize:
prefState.fontSize.fontSize - 4,
color: Palette.orange,
),
),
],
),
textAlign: TextAlign.center,
textScaleFactor:
MediaQuery.of(context).textScaleFactor,
),
),
)
else
const SizedBox(
height: Dimens.pt6,
),
if (state.item.text.isNotEmpty)
FadeIn(
child: SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt10,
),
child: ItemText(
item: state.item,
textScaleFactor:
MediaQuery.of(context).textScaleFactor,
),
),
textAlign: TextAlign.center,
textScaleFactor: MediaQuery.of(
context,
).textScaleFactor,
),
),
)
else
const SizedBox(
height: Dimens.pt6,
),
if (state.item.text.isNotEmpty)
SizedBox(
width: double.infinity,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt10,
),
child: ItemText(
item: state.item,
),
),
),
],
);
},
),
if (state.item.isPoll)
BlocProvider<PollCubit>(
create: (BuildContext context) =>
PollCubit(story: state.item as Story)..init(),
child: const PollView(),
],
);
},
),
],
if (state.item.isPoll)
BlocProvider<PollCubit>(
create: (BuildContext context) =>
PollCubit(story: state.item as Story)..init(),
child: const PollView(),
),
],
),
),
),
if (state.item.text.isNotEmpty)
@ -397,6 +407,7 @@ class _ParentItemSection extends StatelessWidget {
style: const TextStyle(
fontSize: TextDimens.pt13,
),
textScaleFactor: 1,
),
] else ...<Widget>[
const SizedBox(
@ -406,27 +417,29 @@ class _ParentItemSection extends StatelessWidget {
width: _viewParentButtonWidth,
child: TextButton(
onPressed: context.read<CommentsCubit>().loadParentThread,
child: state.fetchParentStatus == CommentsStatus.loading
? const SizedBox(
height: Dimens.pt12,
width: Dimens.pt12,
child: CustomCircularProgressIndicator(
strokeWidth: Dimens.pt2,
),
)
: const Text(
'View parent',
style: TextStyle(
fontSize: TextDimens.pt13,
),
),
child:
state.fetchParentStatus == CommentsStatus.inProgress
? const SizedBox(
height: Dimens.pt12,
width: Dimens.pt12,
child: CustomCircularProgressIndicator(
strokeWidth: Dimens.pt2,
),
)
: const Text(
'View parent',
style: TextStyle(
fontSize: TextDimens.pt13,
),
textScaleFactor: 1,
),
),
),
SizedBox(
width: _viewRootButtonWidth,
child: TextButton(
onPressed: context.read<CommentsCubit>().loadRootThread,
child: state.fetchRootStatus == CommentsStatus.loading
child: state.fetchRootStatus == CommentsStatus.inProgress
? const SizedBox(
height: Dimens.pt12,
width: Dimens.pt12,
@ -439,6 +452,7 @@ class _ParentItemSection extends StatelessWidget {
style: TextStyle(
fontSize: TextDimens.pt13,
),
textScaleFactor: 1,
),
),
),
@ -457,6 +471,7 @@ class _ParentItemSection extends StatelessWidget {
style: const TextStyle(
fontSize: TextDimens.pt13,
),
textScaleFactor: 1,
),
),
)
@ -478,6 +493,7 @@ class _ParentItemSection extends StatelessWidget {
style: const TextStyle(
fontSize: TextDimens.pt13,
),
textScaleFactor: 1,
),
),
)

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:go_router/go_router.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/locator.dart';
import 'package:hacki/cubits/cubits.dart';
@ -15,10 +16,10 @@ import 'package:hacki/utils/utils.dart';
class MorePopupMenu extends StatelessWidget {
const MorePopupMenu({
super.key,
required this.item,
required this.isBlocked,
required this.onLoginTapped,
super.key,
});
final Item item;
@ -60,10 +61,7 @@ class MorePopupMenu extends StatelessWidget {
);
}
Navigator.pop(
context,
MenuAction.upvote,
);
context.pop(MenuAction.upvote);
},
builder: (BuildContext context, VoteState voteState) {
final bool upvoted = voteState.vote == Vote.up;
@ -81,7 +79,7 @@ class MorePopupMenu extends StatelessWidget {
child: BlocBuilder<UserCubit, UserState>(
builder: (BuildContext context, UserState state) {
return Semantics(
excludeSemantics: state.status == UserStatus.loading,
excludeSemantics: state.status == Status.inProgress,
child: ListTile(
leading: const Icon(
Icons.account_circle,
@ -91,7 +89,7 @@ class MorePopupMenu extends StatelessWidget {
state.user.description,
),
onTap: () {
Navigator.pop(context);
context.pop();
showDialog<void>(
context: context,
builder: (BuildContext context) => AlertDialog(
@ -101,10 +99,10 @@ class MorePopupMenu extends StatelessWidget {
'About ${state.user.id}',
),
content: state.user.about.isEmpty
? Row(
? const Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: const <Widget>[
children: <Widget>[
Text(
'empty',
style: TextStyle(
@ -130,7 +128,7 @@ class MorePopupMenu extends StatelessWidget {
locator
.get<AppReviewService>()
.requestReview();
Navigator.pop(context);
context.pop();
onSearchUserTapped(context);
},
child: const Text(
@ -142,7 +140,7 @@ class MorePopupMenu extends StatelessWidget {
locator
.get<AppReviewService>()
.requestReview();
Navigator.pop(context);
context.pop();
},
child: const Text(
'Okay',
@ -196,10 +194,7 @@ class MorePopupMenu extends StatelessWidget {
title: Text(
isFav ? 'Unfavorite' : 'Favorite',
),
onTap: () => Navigator.pop(
context,
MenuAction.fav,
),
onTap: () => context.pop(MenuAction.fav),
);
},
),
@ -208,20 +203,14 @@ class MorePopupMenu extends StatelessWidget {
title: const Text(
'Share',
),
onTap: () => Navigator.pop(
context,
MenuAction.share,
),
onTap: () => context.pop(MenuAction.share),
),
ListTile(
leading: const Icon(Icons.local_police),
title: const Text(
'Flag',
),
onTap: () => Navigator.pop(
context,
MenuAction.flag,
),
onTap: () => context.pop(MenuAction.flag),
),
ListTile(
leading: Icon(
@ -230,20 +219,14 @@ class MorePopupMenu extends StatelessWidget {
title: Text(
isBlocked ? 'Unblock' : 'Block',
),
onTap: () => Navigator.pop(
context,
MenuAction.block,
),
onTap: () => context.pop(MenuAction.block),
),
ListTile(
leading: const Icon(Icons.close),
title: const Text(
'Cancel',
),
onTap: () => Navigator.pop(
context,
MenuAction.cancel,
),
onTap: () => context.pop(MenuAction.cancel),
),
],
),
@ -285,7 +268,7 @@ class MorePopupMenu extends StatelessWidget {
child: SearchScreen(
fromUserDialog: true,
),
)
),
],
),
),

View File

@ -11,8 +11,8 @@ import 'package:hacki/utils/utils.dart';
class PinIconButton extends StatelessWidget {
const PinIconButton({
super.key,
required this.story,
super.key,
});
final Story story;

View File

@ -25,7 +25,7 @@ class _PollViewState extends State<PollView> {
const SizedBox(
height: Dimens.pt24,
),
if (state.status == PollStatus.loading) ...<Widget>[
if (state.status == Status.inProgress) ...<Widget>[
const LinearProgressIndicator(),
const SizedBox(
height: Dimens.pt24,
@ -134,7 +134,7 @@ class _PollViewState extends State<PollView> {
),
],
),
)
),
],
),
);

View File

@ -2,9 +2,11 @@ import 'package:clipboard/clipboard.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_feather_icons/flutter_feather_icons.dart';
import 'package:go_router/go_router.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/item/item.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/screens.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
@ -12,12 +14,12 @@ import 'package:hacki/utils/utils.dart';
class ReplyBox extends StatefulWidget {
const ReplyBox({
super.key,
this.splitViewEnabled = false,
required this.textEditingController,
required this.onSendTapped,
required this.onCloseTapped,
required this.onChanged,
super.key,
this.splitViewEnabled = false,
});
final bool splitViewEnabled;
@ -34,11 +36,12 @@ class _ReplyBoxState extends State<ReplyBox> {
bool expanded = false;
double? expandedHeight;
static const double _collapsedHeight = 100;
static const double collapsedHeight = 140;
@override
Widget build(BuildContext context) {
expandedHeight ??= MediaQuery.of(context).size.height;
expandedHeight ??= MediaQuery.of(context).size.height -
MediaQuery.of(context).viewInsets.bottom;
return BlocBuilder<EditCubit, EditState>(
buildWhen: (EditState previous, EditState current) =>
previous.showReplyBox != current.showReplyBox ||
@ -48,7 +51,7 @@ class _ReplyBoxState extends State<ReplyBox> {
return BlocBuilder<PostCubit, PostState>(
builder: (BuildContext context, PostState postState) {
final Item? replyingTo = editState.replyingTo;
final bool isLoading = postState.status == PostStatus.loading;
final bool isLoading = postState.status.isLoading;
return Padding(
padding: EdgeInsets.only(
@ -59,8 +62,8 @@ class _ReplyBoxState extends State<ReplyBox> {
: Dimens.zero,
),
child: AnimatedContainer(
height: expanded ? expandedHeight : _collapsedHeight,
duration: const Duration(milliseconds: 200),
height: expanded ? expandedHeight : collapsedHeight,
duration: Durations.ms200,
decoration: BoxDecoration(
boxShadow: <BoxShadow>[
if (!context.read<SplitViewCubit>().state.enabled)
@ -72,6 +75,7 @@ class _ReplyBoxState extends State<ReplyBox> {
),
child: Material(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
if (context.read<SplitViewCubit>().state.enabled)
const Divider(
@ -79,7 +83,7 @@ class _ReplyBoxState extends State<ReplyBox> {
),
AnimatedContainer(
height: expanded ? Dimens.pt36 : Dimens.zero,
duration: const Duration(milliseconds: 200),
duration: Durations.ms200,
),
Row(
children: <Widget>[
@ -93,7 +97,7 @@ class _ReplyBoxState extends State<ReplyBox> {
child: Text(
replyingTo == null
? 'Editing'
: 'Replying '
: 'Replying to '
'${replyingTo.by}',
style: const TextStyle(color: Palette.grey),
maxLines: 1,
@ -107,7 +111,7 @@ class _ReplyBoxState extends State<ReplyBox> {
AnimatedOpacity(
opacity:
expanded ? NumSwitch.on : NumSwitch.off,
duration: const Duration(milliseconds: 300),
duration: Durations.ms300,
child: IconButton(
key: const Key('quote'),
icon: const Icon(
@ -141,7 +145,7 @@ class _ReplyBoxState extends State<ReplyBox> {
color: Palette.orange,
),
onPressed: () {
Navigator.pop(context);
context.pop();
final EditState state =
context.read<EditCubit>().state;
@ -158,7 +162,7 @@ class _ReplyBoxState extends State<ReplyBox> {
context
.read<EditCubit>()
.deleteDraft();
Navigator.pop(context);
context.pop();
},
child: const Text(
'No',
@ -168,8 +172,7 @@ class _ReplyBoxState extends State<ReplyBox> {
),
),
TextButton(
onPressed: () =>
Navigator.pop(context),
onPressed: () => context.pop(),
child: const Text('Yes'),
),
],
@ -212,13 +215,21 @@ class _ReplyBoxState extends State<ReplyBox> {
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Dimens.pt16,
padding: EdgeInsets.only(
left: Dimens.pt16,
right: Dimens.pt16,
bottom: expanded
// This padding here prevents keyboard
// overlapping with TextField.
? MediaQuery.of(context).viewInsets.bottom +
Dimens.pt16
: Dimens.zero,
),
child: TextField(
autofocus: true,
controller: widget.textEditingController,
maxLines: 100,
autofocus: true,
expands: true,
maxLines: null,
decoration: const InputDecoration(
alignLabelWithHint: true,
contentPadding: EdgeInsets.zero,
@ -295,12 +306,6 @@ class _ReplyBoxState extends State<ReplyBox> {
setState(() {
expanded = false;
});
Navigator.popUntil(
context,
(Route<dynamic> route) =>
route.settings.name == ItemScreen.routeName ||
route.isFirst,
);
goToItemScreen(
args: ItemScreenArgs(
item: replyingTo,
@ -327,7 +332,7 @@ class _ReplyBoxState extends State<ReplyBox> {
color: Palette.orange,
size: TextDimens.pt18,
),
onPressed: () => Navigator.pop(context),
onPressed: () => context.pop(),
),
],
),
@ -344,6 +349,8 @@ class _ReplyBoxState extends State<ReplyBox> {
child: SingleChildScrollView(
child: ItemText(
item: replyingTo,
textScaleFactor:
MediaQuery.of(context).textScaleFactor,
),
),
),

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/widgets.dart';
@ -8,11 +9,11 @@ import 'package:responsive_builder/responsive_builder.dart';
class TimeMachineDialog extends StatelessWidget {
const TimeMachineDialog({
super.key,
required this.comment,
required this.size,
required this.deviceType,
required this.widthFactor,
super.key,
});
final Comment comment;
@ -56,7 +57,7 @@ class TimeMachineDialog extends StatelessWidget {
Icons.close,
size: Dimens.pt16,
),
onPressed: () => Navigator.pop(context),
onPressed: () => context.pop(),
padding: EdgeInsets.zero,
),
],

View File

@ -1,11 +1,13 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:go_router/go_router.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/profile/models/models.dart';
@ -15,7 +17,6 @@ import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart';
import 'package:pull_to_refresh/pull_to_refresh.dart';
import 'package:tuple/tuple.dart';
class ProfileScreen extends StatefulWidget {
const ProfileScreen({super.key});
@ -30,9 +31,9 @@ class _ProfileScreenState extends State<ProfileScreen>
final RefreshController refreshControllerFav = RefreshController();
final RefreshController refreshControllerNotification = RefreshController();
final ScrollController scrollController = ScrollController();
final Throttle throttle = Throttle(delay: const Duration(seconds: 2));
final Throttle throttle = Throttle(delay: Durations.twoSeconds);
PageType pageType = PageType.notification;
PageType? pageType;
@override
void dispose() {
@ -46,6 +47,9 @@ class _ProfileScreenState extends State<ProfileScreen>
@override
Widget build(BuildContext context) {
pageType ??= context.read<AuthBloc>().state.isLoggedIn
? PageType.notification
: PageType.fav;
super.build(context);
return BlocBuilder<AuthBloc, AuthState>(
builder: (BuildContext context, AuthState authState) {
@ -54,7 +58,7 @@ class _ProfileScreenState extends State<ProfileScreen>
previous.status != current.status,
listener:
(BuildContext context, NotificationState notificationState) {
if (notificationState.status == NotificationStatus.loaded) {
if (notificationState.status == Status.success) {
refreshControllerNotification
..refreshCompleted()
..loadComplete();
@ -72,7 +76,7 @@ class _ProfileScreenState extends State<ProfileScreen>
BuildContext context,
HistoryState historyState,
) {
if (historyState.status == HistoryStatus.loaded) {
if (historyState.status == Status.success) {
refreshControllerHistory
..refreshCompleted()
..loadComplete();
@ -84,7 +88,7 @@ class _ProfileScreenState extends State<ProfileScreen>
) {
if ((!authState.isLoggedIn ||
historyState.submittedItems.isEmpty) &&
historyState.status != HistoryStatus.loading) {
historyState.status != Status.inProgress) {
return const CenteredMessageView(
content: 'Your past comments and stories will '
'show up here.',
@ -92,8 +96,8 @@ class _ProfileScreenState extends State<ProfileScreen>
}
return ItemsListView<Item>(
showWebPreview: false,
showMetadata: false,
showWebPreviewOnStoryTile: false,
showMetadataOnStoryTile: false,
showUrl: false,
useConsistentFontSize: true,
refreshController: refreshControllerHistory,
@ -127,7 +131,7 @@ class _ProfileScreenState extends State<ProfileScreen>
visible: pageType == PageType.fav,
child: BlocConsumer<FavCubit, FavState>(
listener: (BuildContext context, FavState favState) {
if (favState.status == FavStatus.loaded) {
if (favState.status == Status.success) {
refreshControllerFav
..refreshCompleted()
..loadComplete();
@ -135,7 +139,7 @@ class _ProfileScreenState extends State<ProfileScreen>
},
builder: (BuildContext context, FavState favState) {
if (favState.favItems.isEmpty &&
favState.status != FavStatus.loading) {
favState.status != Status.inProgress) {
return const CenteredMessageView(
content: 'Your favorite stories will show up here.'
'\nThey will be synced to your Hacker '
@ -158,8 +162,10 @@ class _ProfileScreenState extends State<ProfileScreen>
PreferenceState prefState,
) {
return ItemsListView<Item>(
showWebPreview: prefState.complexStoryTileEnabled,
showMetadata: prefState.metadataEnabled,
showWebPreviewOnStoryTile:
prefState.complexStoryTileEnabled,
showMetadataOnStoryTile:
prefState.metadataEnabled,
showUrl: prefState.urlEnabled,
useCommentTile: true,
refreshController: refreshControllerFav,
@ -174,6 +180,28 @@ class _ProfileScreenState extends State<ProfileScreen>
onTap: (Item item) => goToItemScreen(
args: ItemScreenArgs(item: item),
),
itemBuilder: (Widget child, Item item) {
return Slidable(
dragStartBehavior: DragStartBehavior.start,
startActionPane: ActionPane(
motion: const BehindMotion(),
children: <Widget>[
SlidableAction(
onPressed: (_) {
HapticFeedbackUtil.light();
context
.read<FavCubit>()
.removeFav(item.id);
},
backgroundColor: Palette.red,
foregroundColor: Palette.white,
icon: Icons.close,
),
],
),
child: child,
);
},
);
},
);
@ -247,8 +275,7 @@ class _ProfileScreenState extends State<ProfileScreen>
selected: false,
onSelected: (bool val) {
if (authState.isLoggedIn) {
HackiApp.navigatorKey.currentState
?.pushNamed(SubmitScreen.routeName);
context.push('/${SubmitScreen.routeName}');
} else {
showSnackBar(
content: 'You need to log in first.',
@ -353,16 +380,18 @@ class _ProfileScreenState extends State<ProfileScreen>
locator
.get<StoriesRepository>()
.fetchParentStoryWithComments(id: comment.parent)
.then((Tuple2<Story, List<Comment>>? tuple) {
if (tuple != null && mounted) {
.then(((Story, List<Comment>)? res) {
if (res != null && mounted) {
final Story parent = res.$1;
final List<Comment> children = res.$2;
goToItemScreen(
args: ItemScreenArgs(
item: tuple.item1,
targetComments: tuple.item2.isEmpty
item: parent,
targetComments: children.isEmpty
? <Comment>[comment]
: <Comment>[
...tuple.item2,
comment.copyWith(level: tuple.item2.length)
...children,
comment.copyWith(level: children.length),
],
onlyShowTargetComment: true,
),

View File

@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hacki/styles/styles.dart';
import 'package:qr_code_scanner/qr_code_scanner.dart';
class QrCodeScannerScreen extends StatefulWidget {
const QrCodeScannerScreen({super.key});
static const String routeName = 'qr-code-scanner';
@override
State<QrCodeScannerScreen> createState() => _QrCodeScannerScreenState();
}
class _QrCodeScannerScreenState extends State<QrCodeScannerScreen> {
final GlobalKey qrKey = GlobalKey(debugLabel: 'QR');
QRViewController? controller;
bool isFlashOn = false;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
elevation: 0,
backgroundColor: Palette.transparent,
actions: <Widget>[
IconButton(
icon: Icon(isFlashOn ? Icons.flash_off : Icons.flash_on),
onPressed: () {
controller?.toggleFlash();
setState(() {
isFlashOn = !isFlashOn;
});
},
),
IconButton(
icon: const Icon(Icons.cameraswitch_outlined),
onPressed: controller?.flipCamera,
),
],
),
extendBodyBehindAppBar: true,
body: Column(
children: <Widget>[
Expanded(
child: QRView(
key: qrKey,
onQRViewCreated: onQRViewCreated,
),
),
],
),
);
}
void onQRViewCreated(QRViewController controller) {
setState(() {
this.controller = controller;
});
controller.scannedDataStream.listen((Barcode scanData) {
controller.stopCamera();
context.pop(scanData.code);
});
}
@override
void dispose() {
controller?.dispose();
super.dispose();
}
}

View File

@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
import 'package:hacki/styles/palette.dart';
import 'package:qr_flutter/qr_flutter.dart';
class QrCodeViewScreen extends StatelessWidget {
const QrCodeViewScreen({
required this.data,
super.key,
});
final String data;
static const String routeName = 'qr-code-view';
static const int qrCodeVersion = 4;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
elevation: 0,
backgroundColor: Palette.transparent,
),
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Center(
child: QrImageView(
data: data,
dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: Theme.of(context).colorScheme.onSurface,
),
eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.square,
color: Theme.of(context).colorScheme.onSurface,
),
version: qrCodeVersion,
size: 300,
),
),
],
),
);
}
}

View File

@ -3,8 +3,8 @@ import 'package:hacki/styles/styles.dart';
class CenteredMessageView extends StatelessWidget {
const CenteredMessageView({
super.key,
required this.content,
super.key,
});
final String content;

View File

@ -8,7 +8,6 @@ import 'package:pull_to_refresh/pull_to_refresh.dart';
class InboxView extends StatelessWidget {
const InboxView({
super.key,
required this.refreshController,
required this.comments,
required this.unreadCommentsIds,
@ -16,6 +15,7 @@ class InboxView extends StatelessWidget {
required this.onMarkAllAsReadTapped,
required this.onLoadMore,
required this.onRefresh,
super.key,
});
final RefreshController refreshController;

View File

@ -1,6 +1,7 @@
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
@ -64,15 +65,15 @@ class OfflineListTile extends StatelessWidget {
title: const Text('Abort downloading?'),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
onPressed: () => context.pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, false),
onPressed: () => context.pop(false),
child: const Text('No'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
onPressed: () => context.pop(true),
child: const Text('Yes'),
),
],
@ -93,15 +94,15 @@ class OfflineListTile extends StatelessWidget {
content: const Text('It will take longer time.'),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
onPressed: () => context.pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(context, false),
onPressed: () => context.pop(false),
child: const Text('No'),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
onPressed: () => context.pop(true),
child: const Text('Yes'),
),
],

View File

@ -1,21 +1,27 @@
import 'dart:async';
import 'dart:io';
import 'package:adaptive_theme/adaptive_theme.dart';
import 'package:clipboard/clipboard.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.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:go_router/go_router.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/config/custom_router.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/qr_code_scanner_screen.dart';
import 'package:hacki/screens/profile/qr_code_view_screen.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';
@ -27,15 +33,15 @@ import 'package:share_plus/share_plus.dart';
class Settings extends StatefulWidget {
const Settings({
super.key,
required this.authState,
required this.magicWord,
required this.pageType,
super.key,
});
final AuthState authState;
final String magicWord;
final PageType pageType;
final PageType? pageType;
@override
State<Settings> createState() => _SettingsState();
@ -74,13 +80,13 @@ class _SettingsState extends State<Settings> {
const SizedBox(
height: Dimens.pt8,
),
Flex(
const Flex(
direction: Axis.horizontal,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Flexible(
child: Row(
children: const <Widget>[
children: <Widget>[
SizedBox(
width: Dimens.pt16,
),
@ -91,7 +97,7 @@ class _SettingsState extends State<Settings> {
),
Flexible(
child: Row(
children: const <Widget>[
children: <Widget>[
Text('Default comments order'),
Spacer(),
],
@ -216,6 +222,51 @@ class _SettingsState extends State<Settings> {
},
activeColor: Palette.orange,
),
if (preference
is MarkReadStoriesModePreference) ...<Widget>[
ListTile(
title: Text(
StoryMarkingModePreference().title,
style: TextStyle(
color: !preferenceState.markReadStoriesEnabled
? Palette.grey
: null,
),
),
trailing: DropdownButton<StoryMarkingMode>(
value: preferenceState.storyMarkingMode,
underline: const SizedBox.shrink(),
items: StoryMarkingMode.values
.map(
(StoryMarkingMode val) =>
DropdownMenuItem<StoryMarkingMode>(
value: val,
child: Text(
val.label,
style: TextStyle(
fontSize: TextDimens.pt16,
color: !preferenceState
.markReadStoriesEnabled
? Palette.grey
: null,
),
),
),
)
.toList(),
onChanged: (StoryMarkingMode? storyMarkingMode) {
if (storyMarkingMode != null) {
HapticFeedbackUtil.selection();
context.read<PreferenceCubit>().update(
StoryMarkingModePreference(),
to: storyMarkingMode.index,
);
}
},
),
),
const Divider(),
],
if (preference is StoryUrlModePreference) const Divider(),
],
ListTile(
@ -243,6 +294,13 @@ class _SettingsState extends State<Settings> {
),
onTap: onExportFavoritesTapped,
),
ListTile(
title: const Text(
'Import Favorites',
),
onTap: () =>
onImportFavoritesTapped(context.read<FavCubit>()),
),
ListTile(
title: const Text(
'Clear Favorites',
@ -288,14 +346,14 @@ class _SettingsState extends State<Settings> {
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
onPressed: () => context.pop(),
child: const Text(
'Cancel',
),
),
TextButton(
onPressed: () {
Navigator.pop(context);
context.pop();
context.read<AuthBloc>().add(AuthLogout());
context.read<HistoryCubit>().reset();
},
@ -338,8 +396,8 @@ class _SettingsState extends State<Settings> {
style: TextStyle(fontFamily: font.name),
),
),
Row(
children: const <Widget>[
const Row(
children: <Widget>[
Text(
'*Restart required',
style: TextStyle(
@ -349,7 +407,7 @@ class _SettingsState extends State<Settings> {
),
Spacer(),
],
)
),
],
),
);
@ -397,18 +455,16 @@ class _SettingsState extends State<Settings> {
switch (val) {
case AdaptiveThemeMode.light:
AdaptiveTheme.of(context).setLight();
break;
case AdaptiveThemeMode.dark:
AdaptiveTheme.of(context).setDark();
break;
case AdaptiveThemeMode.system:
case null:
AdaptiveTheme.of(context).setSystem();
break;
}
final Brightness brightness = Theme.of(context).brightness;
ThemeUtil.updateAndroidStatusBarSetting(brightness, val);
final Brightness brightness =
SchedulerBinding.instance.platformDispatcher.platformBrightness;
ThemeUtil.updateStatusBarSetting(brightness, val);
}
void showClearCacheDialog() {
@ -422,7 +478,7 @@ class _SettingsState extends State<Settings> {
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
onPressed: () => context.pop(),
child: const Text(
'Cancel',
style: TextStyle(
@ -432,7 +488,7 @@ class _SettingsState extends State<Settings> {
),
TextButton(
onPressed: () {
Navigator.pop(context);
context.pop();
locator
.get<SembastRepository>()
.deleteAllCachedComments()
@ -485,8 +541,8 @@ class _SettingsState extends State<Settings> {
onPressed: () => LinkUtil.launch(
Constants.portfolioLink,
),
child: Row(
children: const <Widget>[
child: const Row(
children: <Widget>[
Icon(
FontAwesomeIcons.addressCard,
),
@ -501,8 +557,8 @@ class _SettingsState extends State<Settings> {
onPressed: () => LinkUtil.launch(
Constants.privacyPolicyLink,
),
child: Row(
children: const <Widget>[
child: const Row(
children: <Widget>[
Icon(
Icons.privacy_tip_outlined,
),
@ -515,8 +571,8 @@ class _SettingsState extends State<Settings> {
),
ElevatedButton(
onPressed: onReportIssueTapped,
child: Row(
children: const <Widget>[
child: const Row(
children: <Widget>[
Icon(
Icons.bug_report_outlined,
),
@ -531,8 +587,8 @@ class _SettingsState extends State<Settings> {
onPressed: () => LinkUtil.launch(
Constants.githubLink,
),
child: Row(
children: const <Widget>[
child: const Row(
children: <Widget>[
Icon(
FontAwesomeIcons.github,
),
@ -549,8 +605,8 @@ class _SettingsState extends State<Settings> {
? Constants.appStoreLink
: Constants.googlePlayLink,
),
child: Row(
children: const <Widget>[
child: const Row(
children: <Widget>[
Icon(
Icons.thumb_up,
),
@ -565,8 +621,8 @@ class _SettingsState extends State<Settings> {
onPressed: () => LinkUtil.launch(
Constants.sponsorLink,
),
child: Row(
children: const <Widget>[
child: const Row(
children: <Widget>[
Icon(
FeatherIcons.coffee,
),
@ -590,8 +646,8 @@ class _SettingsState extends State<Settings> {
actions: <Widget>[
ElevatedButton(
onPressed: onSendEmailTapped,
child: Row(
children: const <Widget>[
child: const Row(
children: <Widget>[
Icon(
Icons.email,
),
@ -604,8 +660,8 @@ class _SettingsState extends State<Settings> {
),
ElevatedButton(
onPressed: () => onGithubTapped(context.rect),
child: Row(
children: const <Widget>[
child: const Row(
children: <Widget>[
Icon(
Icons.bug_report_outlined,
),
@ -709,7 +765,7 @@ class _SettingsState extends State<Settings> {
),
),
TextButton(
onPressed: () => Navigator.pop(context),
onPressed: () => context.pop(),
child: const Text(
'Okay',
),
@ -732,7 +788,7 @@ class _SettingsState extends State<Settings> {
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
onPressed: () => context.pop(),
child: const Text(
'Cancel',
),
@ -742,7 +798,7 @@ class _SettingsState extends State<Settings> {
final String keyword = controller.text.trim();
if (keyword.isEmpty) return;
context.read<FilterCubit>().addKeyword(keyword.toLowerCase());
Navigator.pop(context);
context.pop();
},
child: const Text(
'Confirm',
@ -755,20 +811,68 @@ class _SettingsState extends State<Settings> {
}
Future<void> onExportFavoritesTapped() async {
final List<int> allFavorites = context.read<FavCubit>().state.favIds;
return showModalBottomSheet<ExportDestination>(
context: context,
builder: (BuildContext context) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
...ExportDestination.values.map(
(ExportDestination e) => ListTile(
leading: Icon(e.icon),
title: Text(e.label),
onTap: () => context.pop<ExportDestination>(e),
),
),
],
),
);
},
).then(
(ExportDestination? destination) => exportFavorites(to: destination),
);
}
Future<void> onImportFavoritesTapped(FavCubit favCubit) async {
final String? res =
await router.push('/${QrCodeScannerScreen.routeName}') as String?;
final List<int>? ids =
res?.split('\n').map(int.tryParse).whereType<int>().toList();
if (ids == null) return;
for (final int id in ids) {
await favCubit.addFav(id);
}
showSnackBar(content: 'Favorites imported successfully.');
}
Future<void> exportFavorites({required ExportDestination? to}) async {
final ExportDestination? destination = to;
if (destination == null) return;
final List<int> allFavorites = context.read<FavCubit>().state.favIds;
if (allFavorites.isEmpty) {
showSnackBar(content: "You don't have any favorite item.");
return;
}
final String allFavoritesStr = allFavorites.join('\n');
try {
await FlutterClipboard.copy(
allFavorites.join('\n'),
).whenComplete(HapticFeedbackUtil.selection);
showSnackBar(content: 'Ids of favorites have been copied to clipboard.');
} catch (error, stackTrace) {
error.logError(stackTrace: stackTrace);
switch (destination) {
case ExportDestination.qrCode:
await router.push(
'/${QrCodeViewScreen.routeName}',
extra: allFavoritesStr,
);
case ExportDestination.clipBoard:
try {
await FlutterClipboard.copy(allFavoritesStr)
.whenComplete(HapticFeedbackUtil.selection);
showSnackBar(
content: 'Ids of favorites have been copied to clipboard.',
);
} catch (error, stackTrace) {
error.logError(stackTrace: stackTrace);
}
}
}
@ -783,14 +887,14 @@ class _SettingsState extends State<Settings> {
),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context),
onPressed: () => context.pop(),
child: const Text(
'Cancel',
),
),
TextButton(
onPressed: () {
Navigator.pop(context);
context.pop();
try {
context.read<FavCubit>().removeAll();
showSnackBar(content: 'All favorites have been removed.');

View File

@ -20,8 +20,8 @@ class _TabBarSettingsState extends State<TabBarSettings> {
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Row(
children: const <Widget>[
const Row(
children: <Widget>[
SizedBox(
width: Dimens.pt16,
),

View File

@ -1,6 +1,8 @@
export 'home/home_screen.dart';
export 'item/item_screen.dart';
export 'profile/profile_screen.dart';
export 'profile/qr_code_scanner_screen.dart';
export 'profile/qr_code_view_screen.dart';
export 'search/search_screen.dart';
export 'submit/submit_screen.dart';
export 'web_view/web_view_screen.dart';

View File

@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.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';
@ -28,7 +29,17 @@ class SearchScreen extends StatefulWidget {
class _SearchScreenState extends State<SearchScreen> {
final RefreshController refreshController = RefreshController();
final Debouncer debouncer = Debouncer(delay: const Duration(seconds: 1));
final ScrollController scrollController = ScrollController();
final Debouncer debouncer = Debouncer(delay: Durations.oneSecond);
static const Duration chipsAnimationDuration = Durations.ms300;
@override
void dispose() {
refreshController.dispose();
scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
@ -72,6 +83,85 @@ class _SearchScreenState extends State<SearchScreen> {
const SizedBox(
height: Dimens.pt6,
),
AnimatedCrossFade(
duration: chipsAnimationDuration,
crossFadeState: state.showDateRangeShortcutChips
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
firstChild: SizedBox.fromSize(),
secondChild: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: <Widget>[
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.dayBefore(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate: state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.dayAfter(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate: state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.weekBefore(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate: state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.weekAfter(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate: state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.monthBefore(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate: state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
const SizedBox(
width: Dimens.pt8,
),
DateTimeShortcutChip.monthAfter(
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
startDate: state.dateFilter?.startTime,
endDate: state.dateFilter?.endTime,
),
],
),
),
],
),
),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
@ -80,7 +170,9 @@ class _SearchScreenState extends State<SearchScreen> {
width: Dimens.pt8,
),
DateTimeRangeFilterChip(
filter: state.params.get<DateTimeRangeFilter>(),
filter: state.dateFilter,
initialStartDate: state.dateFilter?.startTime,
initialEndDate: state.dateFilter?.endTime,
onDateTimeRangeUpdated: context
.read<SearchCubit>()
.onDateTimeRangeUpdated,
@ -200,11 +292,15 @@ class _SearchScreenState extends State<SearchScreen> {
},
),
controller: refreshController,
scrollController: scrollController,
onRefresh: () {},
onLoading: () {
context.read<SearchCubit>().loadMore();
},
child: ListView(
physics: state.results.isEmpty
? const NeverScrollableScrollPhysics()
: null,
children: <Widget>[
...state.results
.map(

View File

@ -17,9 +17,9 @@ enum CustomDateTimeRange {
class CustomRangeFilterChip extends StatelessWidget {
const CustomRangeFilterChip({
super.key,
required this.range,
required this.onTap,
super.key,
});
final CustomDateTimeRange range;

View File

@ -5,13 +5,17 @@ import 'package:intl/intl.dart';
class DateTimeRangeFilterChip extends StatelessWidget {
const DateTimeRangeFilterChip({
super.key,
required this.filter,
required this.initialStartDate,
required this.initialEndDate,
required this.onDateTimeRangeUpdated,
required this.onDateTimeRangeRemoved,
super.key,
});
final DateTimeRangeFilter? filter;
final DateTime? initialStartDate;
final DateTime? initialEndDate;
final void Function(DateTime, DateTime) onDateTimeRangeUpdated;
final VoidCallback onDateTimeRangeRemoved;
@ -25,6 +29,9 @@ class DateTimeRangeFilterChip extends StatelessWidget {
context: context,
firstDate: DateTime.now().subtract(const Duration(days: 20 * 365)),
lastDate: DateTime.now(),
initialDateRange: initialStartDate != null && initialEndDate != null
? DateTimeRange(start: initialStartDate!, end: initialEndDate!)
: null,
).then((DateTimeRange? range) {
if (range != null) {
onDateTimeRangeUpdated(range.start, range.end);
@ -34,11 +41,22 @@ class DateTimeRangeFilterChip extends StatelessWidget {
});
},
selected: filter != null,
label:
'''from ${_formatDateTime(filter?.startTime) ?? 'X'} to ${_formatDateTime(filter?.endTime) ?? 'Y'}''',
label: _label,
);
}
String get _label {
final DateTime? start = filter?.startTime;
final DateTime? end = filter?.endTime;
if (start == null && end == null) {
return '''from X to Y''';
} else if (start == end) {
return '''from ${_formatDateTime(start)}''';
} else {
return '''from ${_formatDateTime(start)} to ${_formatDateTime(end)}''';
}
}
static String? _formatDateTime(DateTime? dateTime) {
if (dateTime == null) return null;

View File

@ -0,0 +1,89 @@
import 'package:flutter/material.dart';
import 'package:hacki/screens/search/widgets/date_time_range_filter_chip.dart';
import 'package:hacki/screens/widgets/widgets.dart' show CustomChip;
typedef Calculator = DateTime Function(DateTime);
/// A set of chips that perform addition or subtraction on the date selected
/// by [DateTimeRangeFilterChip]
class DateTimeShortcutChip extends StatelessWidget {
const DateTimeShortcutChip({
required this.onDateTimeRangeUpdated,
required this.startDate,
required this.endDate,
required this.label,
required Calculator calculator,
super.key,
}) : _calculator = calculator;
DateTimeShortcutChip.dayBefore({
required this.onDateTimeRangeUpdated,
required this.startDate,
required this.endDate,
super.key,
}) : label = '- day',
_calculator =
((DateTime date) => date.subtract(const Duration(hours: 24)));
DateTimeShortcutChip.dayAfter({
required this.onDateTimeRangeUpdated,
required this.startDate,
required this.endDate,
super.key,
}) : label = '+ day',
_calculator = ((DateTime date) => date.add(const Duration(hours: 24)));
DateTimeShortcutChip.weekBefore({
required this.onDateTimeRangeUpdated,
required this.startDate,
required this.endDate,
super.key,
}) : label = '- week',
_calculator =
((DateTime date) => date.subtract(const Duration(days: 7)));
DateTimeShortcutChip.weekAfter({
required this.onDateTimeRangeUpdated,
required this.startDate,
required this.endDate,
super.key,
}) : label = '+ week',
_calculator = ((DateTime date) => date.add(const Duration(days: 7)));
DateTimeShortcutChip.monthBefore({
required this.onDateTimeRangeUpdated,
required this.startDate,
required this.endDate,
super.key,
}) : label = '- 30 days',
_calculator =
((DateTime date) => date.subtract(const Duration(days: 30)));
DateTimeShortcutChip.monthAfter({
required this.onDateTimeRangeUpdated,
required this.startDate,
required this.endDate,
super.key,
}) : label = '+ 30 days',
_calculator = ((DateTime date) => date.add(const Duration(days: 30)));
final void Function(DateTime, DateTime) onDateTimeRangeUpdated;
final DateTime? startDate;
final DateTime? endDate;
final String label;
final Calculator _calculator;
@override
Widget build(BuildContext context) {
return CustomChip(
onSelected: (bool value) {
if (startDate == null || endDate == null) return;
final DateTime updatedStartDate = _calculator(startDate!);
final DateTime updatedEndDate = _calculator(endDate!);
onDateTimeRangeUpdated(updatedStartDate, updatedEndDate);
},
selected: false,
label: label,
);
}
}

View File

@ -1,13 +1,14 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:hacki/models/search_params.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/styles/styles.dart';
class PostedByFilterChip extends StatelessWidget {
const PostedByFilterChip({
super.key,
required this.filter,
required this.onChanged,
super.key,
});
final PostedByFilter? filter;
@ -67,13 +68,13 @@ class PostedByFilterChip extends StatelessWidget {
child: ButtonBar(
children: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context, filter?.author),
onPressed: () => context.pop(filter?.author),
child: const Text(
'Cancel',
),
),
TextButton(
onPressed: () => Navigator.pop(context, null),
onPressed: () => context.pop(null),
child: const Text(
'Clear',
),
@ -81,7 +82,7 @@ class PostedByFilterChip extends StatelessWidget {
ElevatedButton(
onPressed: () {
final String text = usernameController.text.trim();
Navigator.pop(context, text.isEmpty ? null : text);
context.pop(text.isEmpty ? null : text);
},
style: ButtonStyle(
backgroundColor:

View File

@ -1,3 +1,4 @@
export 'custom_range_filter_chip.dart';
export 'date_time_range_filter_chip.dart';
export 'date_time_shortcut_chip.dart';
export 'posted_by_filter_chip.dart';

Some files were not shown because too many files have changed in this diff Show More