Compare commits

...

15 Commits

Author SHA1 Message Date
fe162208ca fix expand animation. (#142) 2023-02-10 14:08:31 -08:00
58139ba7a3 update commit_check.yml (#141) 2023-02-09 15:42:08 -08:00
33a31acbe2 update Fastfile. 2023-02-09 15:20:19 -08:00
0fcfcbb7e3 update Fastfile. (#140) 2023-02-09 15:12:00 -08:00
a98f52c90b update publish_ios.yml 2023-02-09 14:37:11 -08:00
8e8e48c44a update GitHub action. (#139) 2023-02-09 14:28:46 -08:00
603b7cc939 bump flutter to 3.7.3 (#138) 2023-02-09 11:27:03 -08:00
649fa33df3 fix err msg. (#137) 2023-02-09 00:19:34 -08:00
81d4a0f2df banner cleanup. (#136) 2023-02-08 23:44:15 -08:00
24112a471e add collapse/expand animation to comment tile. (#135) 2023-02-08 23:08:09 -08:00
c7824eaef3 bump flutter to 3.7.2 (#134) 2023-02-08 17:43:23 -08:00
c2b66d29c3 add sharing option. (#131) 2023-02-04 18:46:04 -08:00
e0a53e44b2 bump flutter to 3.7.1 (#129) 2023-02-01 15:19:06 -08:00
4cf8379db0 fix Story model. (#128) 2023-01-31 22:02:17 -08:00
c1c26bf0e0 fix preference model. (#127) 2023-01-31 18:19:34 -08:00
36 changed files with 395 additions and 383 deletions

View File

@ -11,15 +11,13 @@ jobs:
name: Check commit name: Check commit
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 30
env:
FLUTTER_VERSION: "3.7.0"
steps: steps:
- uses: actions/checkout@v2 - name: checkout all the submodules
- uses: subosito/flutter-action@v2 uses: actions/checkout@v3
with: with:
flutter-version: '3.7.0' submodules: recursive
channel: 'stable' - run: submodules/flutter/bin/flutter doctor
- run: flutter pub get - run: submodules/flutter/bin/flutter pub get
- run: flutter format --set-exit-if-changed . - run: submodules/flutter/bin/dart format --set-exit-if-changed lib test integration_test
- run: flutter analyze - run: submodules/flutter/bin/flutter analyze lib test integration_test
- run: flutter test - run: submodules/flutter/bin/flutter test

View File

@ -20,21 +20,21 @@ jobs:
steps: steps:
- name: Check out from git - name: Check out from git
uses: actions/checkout@v2 uses: actions/checkout@v3
with:
submodules: recursive
- 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
- run: submodules/flutter/bin/flutter analyze lib test integration_test
- run: submodules/flutter/bin/flutter test
# Configure ruby according to our .ruby-version # Configure ruby according to our .ruby-version
- name: Setup ruby & Bundler - name: Setup ruby & Bundler
uses: ruby/setup-ruby@v1 uses: ruby/setup-ruby@v1
with: with:
bundler-cache: true bundler-cache: true
# Set up flutter (feel free to adjust the version below)
- name: Setup flutter
uses: subosito/flutter-action@v2
with:
cache: true
flutter-version: 3.7.0
- run: flutter pub get
- run: flutter format --set-exit-if-changed .
- run: flutter analyze
# Start an ssh-agent that will provide the SSH key from the # Start an ssh-agent that will provide the SSH key from the
# SSH_PRIVATE_KEY secret to `fastlane match` # SSH_PRIVATE_KEY secret to `fastlane match`
- name: Setup SSH key - name: Setup SSH key
@ -43,8 +43,7 @@ jobs:
run: | run: |
ssh-agent -a $SSH_AUTH_SOCK > /dev/null ssh-agent -a $SSH_AUTH_SOCK > /dev/null
ssh-add - <<< "${{ secrets.SSH_PRIVATE_KEY }}" ssh-add - <<< "${{ secrets.SSH_PRIVATE_KEY }}"
- name: Download dependencies
run: flutter pub get
- name: Build & Publish to TestFlight with Fastlane - name: Build & Publish to TestFlight with Fastlane
env: env:
APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }} APP_STORE_CONNECT_API_KEY_KEY: ${{ secrets.APP_STORE_CONNECT_API_KEY_KEY }}

View File

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

View File

@ -49,7 +49,7 @@ latest_testflight_build_number
# Prep the xcodeproject from Flutter without building (`--config-only`) # Prep the xcodeproject from Flutter without building (`--config-only`)
sh( sh(
"flutter", "build", "ios", "--config-only", "/Users/runner/work/Hacki/Hacki/submodules/flutter/bin/flutter", "build", "ios", "--config-only",
"--release", "--no-pub", "--no-codesign", "--release", "--no-pub", "--no-codesign",
"--build-number", new_build_number.to_s "--build-number", new_build_number.to_s
) )

View File

@ -56,6 +56,8 @@ abstract class Constants {
'ʕ•́ᴥ•̀ʔっ', 'ʕ•́ᴥ•̀ʔっ',
'(ㆆ_ㆆ)', '(ㆆ_ㆆ)',
].pickRandomly()!; ].pickRandomly()!;
static final String errorMessage = 'Something went wrong...$sadFace';
} }
abstract class RegExpConstants { abstract class RegExpConstants {

View File

@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/screens/screens.dart'; import 'package:hacki/screens/screens.dart';
/// Custom router. /// Custom router.
@ -39,8 +40,8 @@ class CustomRouter {
appBar: AppBar( appBar: AppBar(
title: const Text('Error'), title: const Text('Error'),
), ),
body: const Center( body: Center(
child: Text('Something went wrong!'), child: Text(Constants.errorMessage),
), ),
), ),
); );

View File

@ -20,7 +20,7 @@ Future<void> setUpLocator() async {
Logger( Logger(
filter: CustomLogFilter(), filter: CustomLogFilter(),
printer: LogUtil.logPrinter, printer: LogUtil.logPrinter,
output: LogUtil.getLogOutput(logOutputFile), output: LogUtil.logOutput(logOutputFile),
), ),
) )
..registerSingleton<StoriesRepository>(StoriesRepository()) ..registerSingleton<StoriesRepository>(StoriesRepository())

View File

@ -2,6 +2,8 @@ import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/config/constants.dart';
import 'package:hacki/styles/styles.dart';
extension ContextExtension on BuildContext { extension ContextExtension on BuildContext {
T? tryRead<T>() { T? tryRead<T>() {
@ -12,6 +14,31 @@ extension ContextExtension on BuildContext {
} }
} }
void showSnackBar({
required String content,
VoidCallback? action,
String? label,
}) {
ScaffoldMessenger.of(this).showSnackBar(
SnackBar(
backgroundColor: Palette.deepOrange,
content: Text(content),
action: action != null && label != null
? SnackBarAction(
label: label,
onPressed: action,
textColor: Theme.of(this).textTheme.bodyLarge?.color,
)
: null,
behavior: SnackBarBehavior.floating,
),
);
}
void showErrorSnackBar() => showSnackBar(
content: Constants.errorMessage,
);
Rect? get rect { Rect? get rect {
final RenderBox? box = findRenderObject() as RenderBox?; final RenderBox? box = findRenderObject() as RenderBox?;
final Rect? rect = final Rect? rect =

View File

@ -21,22 +21,15 @@ extension StateExtension on State {
VoidCallback? action, VoidCallback? action,
String? label, String? label,
}) { }) {
ScaffoldMessenger.of(context).showSnackBar( context.showSnackBar(
SnackBar( content: content,
backgroundColor: Palette.deepOrange, action: action,
content: Text(content), label: label,
action: action != null && label != null
? SnackBarAction(
label: label,
onPressed: action,
textColor: Theme.of(context).textTheme.bodyLarge?.color,
)
: null,
behavior: SnackBarBehavior.floating,
),
); );
} }
void showErrorSnackBar() => context.showErrorSnackBar();
Future<void>? goToItemScreen({ Future<void>? goToItemScreen({
required ItemScreenArgs args, required ItemScreenArgs args,
bool forceNewScreen = false, bool forceNewScreen = false,
@ -70,7 +63,6 @@ extension StateExtension on State {
return MorePopupMenu( return MorePopupMenu(
item: item, item: item,
isBlocked: isBlocked, isBlocked: isBlocked,
showSnackBar: showSnackBar,
onStoryLinkTapped: onStoryLinkTapped, onStoryLinkTapped: onStoryLinkTapped,
onLoginTapped: onLoginTapped, onLoginTapped: onLoginTapped,
); );
@ -119,11 +111,45 @@ extension StateExtension on State {
} }
} }
void onShareTapped(Item item, Rect? rect) { Future<void> onShareTapped(Item item, Rect? rect) async {
Share.share( late final String? linkToShare;
'https://news.ycombinator.com/item?id=${item.id}', if (item.url.isNotEmpty) {
sharePositionOrigin: rect, linkToShare = await showModalBottomSheet<String>(
); context: context,
builder: (BuildContext context) {
return Container(
height: 140,
color: Theme.of(context).canvasColor,
child: Material(
child: Column(
children: <Widget>[
ListTile(
onTap: () => Navigator.pop(context, item.url),
title: const Text('Link to article'),
),
ListTile(
onTap: () => Navigator.pop(
context,
'https://news.ycombinator.com/item?id=${item.id}',
),
title: const Text('Link to HN'),
),
],
),
),
);
},
);
} else {
linkToShare = 'https://news.ycombinator.com/item?id=${item.id}';
}
if (linkToShare != null) {
await Share.share(
linkToShare,
sharePositionOrigin: rect,
);
}
} }
void onFlagTapped(Item item) { void onFlagTapped(Item item) {

View File

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

View File

@ -24,22 +24,7 @@ class PollOption extends Item {
PollOption.empty() PollOption.empty()
: ratio = 0, : ratio = 0,
super( super.empty();
id: 0,
score: 0,
descendants: 0,
time: 0,
by: '',
title: '',
url: '',
kids: <int>[],
dead: false,
parts: <int>[],
deleted: false,
parent: 0,
text: '',
type: '',
);
PollOption.fromJson(super.json) PollOption.fromJson(super.json)
: ratio = 0, : ratio = 0,

View File

@ -1,3 +1,4 @@
import 'dart:collection';
import 'dart:io'; import 'dart:io';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
@ -13,26 +14,29 @@ abstract class Preference<T> extends Equatable with SettingsDisplayable {
Preference<T> copyWith({required T? val}); Preference<T> copyWith({required T? val});
static List<Preference<dynamic>> allPreferences = <Preference<dynamic>>[ static final List<Preference<dynamic>> allPreferences =
// Order of these first three preferences does not matter. UnmodifiableListView<Preference<dynamic>>(
FetchModePreference(), <Preference<dynamic>>[
CommentsOrderPreference(), // Order of these first four preferences does not matter.
FontSizePreference(), FetchModePreference(),
TabOrderPreference(), CommentsOrderPreference(),
// Order of items below matters and FontSizePreference(),
// reflects the order on settings screen. TabOrderPreference(),
const DisplayModePreference(), // Order of items below matters and
const MetadataModePreference(), // reflects the order on settings screen.
const StoryUrlModePreference(), const DisplayModePreference(),
const NotificationModePreference(), const MetadataModePreference(),
const SwipeGesturePreference(), const StoryUrlModePreference(),
const CollapseModePreference(), const NotificationModePreference(),
NavigationModePreference(), const SwipeGesturePreference(),
const ReaderModePreference(), const CollapseModePreference(),
const MarkReadStoriesModePreference(), NavigationModePreference(),
const EyeCandyModePreference(), const ReaderModePreference(),
const TrueDarkModePreference(), const MarkReadStoriesModePreference(),
]; const EyeCandyModePreference(),
const TrueDarkModePreference(),
],
);
@override @override
List<Object?> get props => <Object?>[key]; List<Object?> get props => <Object?>[key];
@ -81,7 +85,7 @@ class SwipeGesturePreference extends BooleanPreference {
@override @override
String get subtitle => String get subtitle =>
'''Enable swipe gesture for switching between tabs. If enabled, long press on Story tile to trigger the action menu.'''; '''enable swipe gesture for switching between tabs. If enabled, long press on Story tile to trigger the action menu.''';
} }
class NotificationModePreference extends BooleanPreference { class NotificationModePreference extends BooleanPreference {
@ -118,6 +122,10 @@ class CollapseModePreference extends BooleanPreference {
@override @override
String get title => 'Tap Anywhere to Collapse'; String get title => 'Tap Anywhere to Collapse';
@override
String get subtitle =>
'''if disabled, tap on the top of comment tile to collapse.''';
} }
/// The value deciding whether or not the story /// The value deciding whether or not the story

View File

@ -1,41 +1,6 @@
import 'package:hacki/config/constants.dart'; import 'package:hacki/config/constants.dart';
import 'package:hacki/models/item.dart'; import 'package:hacki/models/item.dart';
enum StoryType {
top('topstories'),
best('beststories'),
latest('newstories'),
ask('askstories'),
show('showstories');
const StoryType(this.path);
final String path;
String get label {
switch (this) {
case StoryType.top:
return 'TOP';
case StoryType.best:
return 'BEST';
case StoryType.latest:
return 'NEW';
case StoryType.ask:
return 'ASK';
case StoryType.show:
return 'SHOW';
}
}
static int convertToSettingsValue(List<StoryType> tabs) {
return int.parse(
tabs
.map((StoryType e) => e.index.toString())
.reduce((String value, String element) => '$value$element'),
);
}
}
class Story extends Item { class Story extends Item {
const Story({ const Story({
required super.descendants, required super.descendants,
@ -55,23 +20,7 @@ class Story extends Item {
parent: 0, parent: 0,
); );
Story.empty() Story.empty() : super.empty();
: super(
id: 0,
score: 0,
descendants: 0,
time: 0,
by: '',
title: '',
url: '',
kids: <int>[],
dead: false,
parts: <int>[],
deleted: false,
parent: 0,
text: '',
type: '',
);
Story.placeholder() Story.placeholder()
: super( : super(

View File

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

View File

@ -3,7 +3,7 @@ import 'dart:async';
import 'package:hacki/config/locator.dart'; import 'package:hacki/config/locator.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/repositories/postable_repository.dart'; import 'package:hacki/repositories/postable_repository.dart';
import 'package:hacki/repositories/repositories.dart'; import 'package:hacki/repositories/preference_repository.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
class AuthRepository extends PostableRepository { class AuthRepository extends PostableRepository {
@ -18,8 +18,6 @@ class AuthRepository extends PostableRepository {
final PreferenceRepository _preferenceRepository; final PreferenceRepository _preferenceRepository;
final Logger _logger; final Logger _logger;
static const String _authority = 'news.ycombinator.com';
Future<bool> get loggedIn async => _preferenceRepository.loggedIn; Future<bool> get loggedIn async => _preferenceRepository.loggedIn;
Future<String?> get username async => _preferenceRepository.username; Future<String?> get username async => _preferenceRepository.username;
@ -30,7 +28,7 @@ class AuthRepository extends PostableRepository {
required String username, required String username,
required String password, required String password,
}) async { }) async {
final Uri uri = Uri.https(_authority, 'login'); final Uri uri = Uri.https(authority, 'login');
final PostDataMixin data = LoginPostData( final PostDataMixin data = LoginPostData(
acct: username, acct: username,
pw: password, pw: password,
@ -64,7 +62,7 @@ class AuthRepository extends PostableRepository {
required int id, required int id,
required bool flag, required bool flag,
}) async { }) async {
final Uri uri = Uri.https(_authority, 'flag'); final Uri uri = Uri.https(authority, 'flag');
final String? username = await _preferenceRepository.username; final String? username = await _preferenceRepository.username;
final String? password = await _preferenceRepository.password; final String? password = await _preferenceRepository.password;
final PostDataMixin data = FlagPostData( final PostDataMixin data = FlagPostData(
@ -81,7 +79,7 @@ class AuthRepository extends PostableRepository {
required int id, required int id,
required bool favorite, required bool favorite,
}) async { }) async {
final Uri uri = Uri.https(_authority, 'fave'); final Uri uri = Uri.https(authority, 'fave');
final String? username = await _preferenceRepository.username; final String? username = await _preferenceRepository.username;
final String? password = await _preferenceRepository.password; final String? password = await _preferenceRepository.password;
final PostDataMixin data = FavoritePostData( final PostDataMixin data = FavoritePostData(
@ -98,7 +96,7 @@ class AuthRepository extends PostableRepository {
required int id, required int id,
required bool upvote, required bool upvote,
}) async { }) async {
final Uri uri = Uri.https(_authority, 'vote'); final Uri uri = Uri.https(authority, 'vote');
final String? username = await _preferenceRepository.username; final String? username = await _preferenceRepository.username;
final String? password = await _preferenceRepository.password; final String? password = await _preferenceRepository.password;
final PostDataMixin data = VotePostData( final PostDataMixin data = VotePostData(
@ -115,7 +113,7 @@ class AuthRepository extends PostableRepository {
required int id, required int id,
required bool downvote, required bool downvote,
}) async { }) async {
final Uri uri = Uri.https(_authority, 'vote'); final Uri uri = Uri.https(authority, 'vote');
final String? username = await _preferenceRepository.username; final String? username = await _preferenceRepository.username;
final String? password = await _preferenceRepository.password; final String? password = await _preferenceRepository.password;
final PostDataMixin data = VotePostData( final PostDataMixin data = VotePostData(

View File

@ -14,15 +14,13 @@ class PostRepository extends PostableRepository {
final PreferenceRepository _preferenceRepository; final PreferenceRepository _preferenceRepository;
static const String _authority = 'news.ycombinator.com';
Future<bool> comment({ Future<bool> comment({
required int parentId, required int parentId,
required String text, required String text,
}) async { }) async {
final String? username = await _preferenceRepository.username; final String? username = await _preferenceRepository.username;
final String? password = await _preferenceRepository.password; final String? password = await _preferenceRepository.password;
final Uri uri = Uri.https(_authority, 'comment'); final Uri uri = Uri.https(authority, 'comment');
if (username == null || password == null) { if (username == null || password == null) {
return false; return false;
@ -54,7 +52,7 @@ class PostRepository extends PostableRepository {
return false; return false;
} }
final Response<List<int>> formResponse = await _getFormResponse( final Response<List<int>> formResponse = await getFormResponse(
username: username, username: username,
password: password, password: password,
path: 'submitlink', path: 'submitlink',
@ -69,7 +67,7 @@ class PostRepository extends PostableRepository {
final String? cookie = final String? cookie =
formResponse.headers.value(HttpHeaders.setCookieHeader); formResponse.headers.value(HttpHeaders.setCookieHeader);
final Uri uri = Uri.https(_authority, 'r'); final Uri uri = Uri.https(authority, 'r');
final PostDataMixin data = SubmitPostData( final PostDataMixin data = SubmitPostData(
fnid: formValues['fnid']!, fnid: formValues['fnid']!,
fnop: formValues['fnop']!, fnop: formValues['fnop']!,
@ -97,7 +95,7 @@ class PostRepository extends PostableRepository {
return false; return false;
} }
final Response<List<int>> formResponse = await _getFormResponse( final Response<List<int>> formResponse = await getFormResponse(
username: username, username: username,
password: password, password: password,
id: id, id: id,
@ -113,7 +111,7 @@ class PostRepository extends PostableRepository {
final String? cookie = final String? cookie =
formResponse.headers.value(HttpHeaders.setCookieHeader); formResponse.headers.value(HttpHeaders.setCookieHeader);
final Uri uri = Uri.https(_authority, 'xedit'); final Uri uri = Uri.https(authority, 'xedit');
final PostDataMixin data = EditPostData( final PostDataMixin data = EditPostData(
hmac: formValues['hmac']!, hmac: formValues['hmac']!,
id: id, id: id,
@ -126,28 +124,4 @@ class PostRepository extends PostableRepository {
cookie: cookie, cookie: cookie,
); );
} }
Future<Response<List<int>>> _getFormResponse({
required String username,
required String password,
required String path,
int? id,
}) async {
final Uri uri = Uri.https(
_authority,
path,
<String, dynamic>{if (id != null) 'id': id.toString()},
);
final PostDataMixin data = FormPostData(
acct: username,
pw: password,
id: id,
);
return performPost(
uri,
data,
responseType: ResponseType.bytes,
validateStatus: (int? status) => status == HttpStatus.ok,
);
}
} }

View File

@ -8,10 +8,14 @@ import 'package:hacki/utils/service_exception.dart';
class PostableRepository { class PostableRepository {
PostableRepository({ PostableRepository({
Dio? dio, Dio? dio,
this.authority = 'news.ycombinator.com',
}) : _dio = dio ?? Dio(); }) : _dio = dio ?? Dio();
final Dio _dio; final Dio _dio;
@protected
final String authority;
@protected @protected
Future<bool> performDefaultPost( Future<bool> performDefaultPost(
Uri uri, Uri uri,
@ -60,4 +64,29 @@ class PostableRepository {
throw ServiceException(e.message); throw ServiceException(e.message);
} }
} }
@protected
Future<Response<List<int>>> getFormResponse({
required String username,
required String password,
required String path,
int? id,
}) async {
final Uri uri = Uri.https(
authority,
path,
<String, dynamic>{if (id != null) 'id': id.toString()},
);
final PostDataMixin data = FormPostData(
acct: username,
pw: password,
id: id,
);
return performPost(
uri,
data,
responseType: ResponseType.bytes,
validateStatus: (int? status) => status == HttpStatus.ok,
);
}
} }

View File

@ -301,7 +301,7 @@ class _HomeScreenState extends State<HomeScreen>
.fetchStoryBy(storyId) .fetchStoryBy(storyId)
.then((Story? story) { .then((Story? story) {
if (story == null) { if (story == null) {
showSnackBar(content: 'Something went wrong...'); showErrorSnackBar();
return; return;
} }
final ItemScreenArgs args = ItemScreenArgs(item: story); final ItemScreenArgs args = ItemScreenArgs(item: story);
@ -326,7 +326,7 @@ class _HomeScreenState extends State<HomeScreen>
.fetchStoryBy(storyId) .fetchStoryBy(storyId)
.then((Story? story) { .then((Story? story) {
if (story == null) { if (story == null) {
showSnackBar(content: 'Something went wrong...'); showErrorSnackBar();
return; return;
} }
final ItemScreenArgs args = ItemScreenArgs(item: story); final ItemScreenArgs args = ItemScreenArgs(item: story);

View File

@ -239,12 +239,7 @@ class _ItemScreenState extends State<ItemScreen> with RouteAware {
context.read<EditCubit>().onReplySubmittedSuccessfully(); context.read<EditCubit>().onReplySubmittedSuccessfully();
context.read<PostCubit>().reset(); context.read<PostCubit>().reset();
} else if (postState.status == PostStatus.failure) { } else if (postState.status == PostStatus.failure) {
showSnackBar( showErrorSnackBar();
content: 'Something went wrong...'
'${Constants.sadFace}',
label: 'Okay',
action: ScaffoldMessenger.of(context).hideCurrentSnackBar,
);
context.read<PostCubit>().reset(); context.read<PostCubit>().reset();
} }
}, },

View File

@ -87,13 +87,13 @@ class LoginDialog extends StatelessWidget {
height: Dimens.pt16, height: Dimens.pt16,
), ),
if (state.status == AuthStatus.failure) if (state.status == AuthStatus.failure)
const Padding( Padding(
padding: EdgeInsets.only( padding: const EdgeInsets.only(
left: Dimens.pt18, left: Dimens.pt18,
), ),
child: Text( child: Text(
'Something went wrong...', Constants.errorMessage,
style: TextStyle( style: const TextStyle(
color: Palette.grey, color: Palette.grey,
fontSize: TextDimens.pt12, fontSize: TextDimens.pt12,
), ),

View File

@ -15,18 +15,12 @@ class MorePopupMenu extends StatelessWidget {
super.key, super.key,
required this.item, required this.item,
required this.isBlocked, required this.isBlocked,
required this.showSnackBar,
required this.onStoryLinkTapped, required this.onStoryLinkTapped,
required this.onLoginTapped, required this.onLoginTapped,
}); });
final Item item; final Item item;
final bool isBlocked; final bool isBlocked;
final void Function({
required String content,
VoidCallback? action,
String? label,
}) showSnackBar;
final ValueChanged<String> onStoryLinkTapped; final ValueChanged<String> onStoryLinkTapped;
final VoidCallback onLoginTapped; final VoidCallback onLoginTapped;
@ -43,24 +37,26 @@ class MorePopupMenu extends StatelessWidget {
}, },
listener: (BuildContext context, VoteState voteState) { listener: (BuildContext context, VoteState voteState) {
if (voteState.status == VoteStatus.submitted) { if (voteState.status == VoteStatus.submitted) {
showSnackBar(content: 'Vote submitted successfully.'); context.showSnackBar(content: 'Vote submitted successfully.');
} else if (voteState.status == VoteStatus.canceled) { } else if (voteState.status == VoteStatus.canceled) {
showSnackBar(content: 'Vote canceled.'); context.showSnackBar(content: 'Vote canceled.');
} else if (voteState.status == VoteStatus.failure) { } else if (voteState.status == VoteStatus.failure) {
showSnackBar(content: 'Something went wrong...'); context.showErrorSnackBar();
} else if (voteState.status == } else if (voteState.status ==
VoteStatus.failureKarmaBelowThreshold) { VoteStatus.failureKarmaBelowThreshold) {
showSnackBar( context.showSnackBar(
content: "You can't downvote because you are karmaly broke.", content: "You can't downvote because you are karmaly broke.",
); );
} else if (voteState.status == VoteStatus.failureNotLoggedIn) { } else if (voteState.status == VoteStatus.failureNotLoggedIn) {
showSnackBar( context.showSnackBar(
content: 'Not logged in, no voting! (;O´)o', content: 'Not logged in, no voting! (;O´)o',
action: onLoginTapped, action: onLoginTapped,
label: 'Log in', label: 'Log in',
); );
} else if (voteState.status == VoteStatus.failureBeHumble) { } else if (voteState.status == VoteStatus.failureBeHumble) {
showSnackBar(content: 'No voting on your own post! (;O´)o'); context.showSnackBar(
content: 'No voting on your own post! (;O´)o',
);
} }
Navigator.pop( Navigator.pop(

View File

@ -4,6 +4,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart'; import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:hacki/blocs/auth/auth_bloc.dart'; import 'package:hacki/blocs/auth/auth_bloc.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/context_extension.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
@ -61,36 +62,29 @@ class PollView extends StatelessWidget {
listener: (BuildContext context, VoteState voteState) { listener: (BuildContext context, VoteState voteState) {
ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).clearSnackBars();
if (voteState.status == VoteStatus.submitted) { if (voteState.status == VoteStatus.submitted) {
showSnackBar( context.showSnackBar(
context,
content: 'Vote submitted successfully.', content: 'Vote submitted successfully.',
); );
} else if (voteState.status == VoteStatus.canceled) { } else if (voteState.status == VoteStatus.canceled) {
showSnackBar(context, content: 'Vote canceled.'); context.showSnackBar(content: 'Vote canceled.');
} else if (voteState.status == VoteStatus.failure) { } else if (voteState.status == VoteStatus.failure) {
showSnackBar( context.showErrorSnackBar();
context,
content: 'Something went wrong...',
);
} else if (voteState.status == } else if (voteState.status ==
VoteStatus.failureKarmaBelowThreshold) { VoteStatus.failureKarmaBelowThreshold) {
showSnackBar( context.showSnackBar(
context,
content: "You can't downvote because" content: "You can't downvote because"
' you are karmaly broke.', ' you are karmaly broke.',
); );
} else if (voteState.status == } else if (voteState.status ==
VoteStatus.failureNotLoggedIn) { VoteStatus.failureNotLoggedIn) {
showSnackBar( context.showSnackBar(
context,
content: 'Not logged in, no voting! (;O´)o', content: 'Not logged in, no voting! (;O´)o',
action: onLoginTapped, action: onLoginTapped,
label: 'Log in', label: 'Log in',
); );
} else if (voteState.status == } else if (voteState.status ==
VoteStatus.failureBeHumble) { VoteStatus.failureBeHumble) {
showSnackBar( context.showSnackBar(
context,
content: 'No voting on your own post! (;O´)o', content: 'No voting on your own post! (;O´)o',
); );
} }
@ -153,26 +147,4 @@ class PollView extends StatelessWidget {
}, },
); );
} }
void showSnackBar(
BuildContext context, {
required String content,
VoidCallback? action,
String? label,
}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
backgroundColor: Palette.deepOrange,
content: Text(content),
action: action != null && label != null
? SnackBarAction(
label: label,
onPressed: action,
textColor: Theme.of(context).textTheme.bodyLarge?.color,
)
: null,
behavior: SnackBarBehavior.floating,
),
);
}
} }

View File

@ -453,13 +453,13 @@ class _ProfileScreenState extends State<ProfileScreen>
height: Dimens.pt16, height: Dimens.pt16,
), ),
if (state.status == AuthStatus.failure) if (state.status == AuthStatus.failure)
const Padding( Padding(
padding: EdgeInsets.only( padding: const EdgeInsets.only(
left: Dimens.pt18, left: Dimens.pt18,
), ),
child: Text( child: Text(
'Something went wrong...', Constants.errorMessage,
style: TextStyle( style: const TextStyle(
color: Palette.grey, color: Palette.grey,
fontSize: TextDimens.pt12, fontSize: TextDimens.pt12,
), ),

View File

@ -50,9 +50,7 @@ class _SubmitScreenState extends State<SubmitScreen> {
content: 'Post submitted successfully.', content: 'Post submitted successfully.',
); );
} else if (state.status == SubmitStatus.failure) { } else if (state.status == SubmitStatus.failure) {
showSnackBar( showErrorSnackBar();
content: 'Something went wrong...',
);
} }
}, },
builder: (BuildContext context, SubmitState state) { builder: (BuildContext context, SubmitState state) {

View File

@ -0,0 +1,48 @@
import 'package:flutter/material.dart';
import 'package:hacki/styles/styles.dart';
class CenteredText extends StatelessWidget {
const CenteredText({
super.key,
required this.text,
this.color = Palette.grey,
});
const CenteredText.deleted({Key? key})
: this(
key: key,
text: 'deleted',
);
const CenteredText.dead({Key? key})
: this(
key: key,
text: 'dead',
);
const CenteredText.blocked({Key? key})
: this(
key: key,
text: 'blocked',
);
final String text;
final Color color;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.only(
bottom: Dimens.pt12,
),
child: Text(
text,
style: TextStyle(
color: color,
),
),
),
);
}
}

View File

@ -8,6 +8,7 @@ import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/extensions/extensions.dart'; import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/bloc_builder_3.dart'; import 'package:hacki/screens/widgets/bloc_builder_3.dart';
import 'package:hacki/screens/widgets/centered_text.dart';
import 'package:hacki/services/services.dart'; import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
import 'package:hacki/utils/utils.dart'; import 'package:hacki/utils/utils.dart';
@ -40,6 +41,8 @@ class CommentTile extends StatelessWidget {
final void Function(String) onStoryLinkTapped; final void Function(String) onStoryLinkTapped;
final FetchMode fetchMode; final FetchMode fetchMode;
static final Map<int, Color> _colors = <int, Color>{};
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider<CollapseCubit>( return BlocProvider<CollapseCubit>(
@ -157,136 +160,92 @@ class CommentTile extends StatelessWidget {
], ],
), ),
), ),
if (actionable && state.collapsed) AnimatedSize(
Center( duration: const Duration(milliseconds: 200),
child: Padding( child: Column(
padding: const EdgeInsets.only( crossAxisAlignment: CrossAxisAlignment.start,
bottom: Dimens.pt12, children: <Widget>[
), if (actionable && state.collapsed)
child: Text( CenteredText(
'collapsed ' text:
'(${state.collapsedCount + 1})', '''collapsed (${state.collapsedCount + 1})''',
style: const TextStyle(
color: Palette.orangeAccent, color: Palette.orangeAccent,
), )
), else if (comment.deleted)
), const CenteredText.deleted()
) else if (comment.dead)
else if (comment.deleted) const CenteredText.dead()
const Center( else if (blocklistState.blocklist
child: Padding( .contains(comment.by))
padding: EdgeInsets.only( const CenteredText.blocked()
bottom: Dimens.pt12, else
), Padding(
child: Text( padding: const EdgeInsets.only(
'deleted', left: Dimens.pt8,
style: TextStyle( right: Dimens.pt8,
color: Palette.grey, top: Dimens.pt6,
), bottom: Dimens.pt12,
),
),
)
else if (comment.dead)
const Center(
child: Padding(
padding: EdgeInsets.only(
bottom: Dimens.pt12,
),
child: Text(
'dead',
style: TextStyle(
color: Palette.grey,
),
),
),
)
else if (blocklistState.blocklist.contains(comment.by))
const Center(
child: Padding(
padding: EdgeInsets.only(
bottom: Dimens.pt12,
),
child: Text(
'blocked',
style: TextStyle(
color: Palette.grey,
),
),
),
)
else
Padding(
padding: const EdgeInsets.only(
left: Dimens.pt8,
right: Dimens.pt8,
top: Dimens.pt6,
bottom: Dimens.pt12,
),
child: comment is BuildableComment
? SelectableText.rich(
key: ValueKey<int>(comment.id),
buildTextSpan(
(comment as BuildableComment).elements,
style: TextStyle(
fontSize: MediaQuery.of(
context,
).textScaleFactor *
prefState.fontSize.fontSize,
),
linkStyle: TextStyle(
fontSize: MediaQuery.of(
context,
).textScaleFactor *
prefState.fontSize.fontSize,
decoration: TextDecoration.underline,
color: Palette.orange,
),
onOpen: (LinkableElement link) {
if (link.url.isStoryLink) {
onStoryLinkTapped.call(link.url);
} else {
LinkUtil.launch(link.url);
}
},
),
onTap: () => onTextTapped(context),
)
: SelectableLinkify(
key: ValueKey<int>(comment.id),
text: comment.text,
style: TextStyle(
fontSize: MediaQuery.of(context)
.textScaleFactor *
prefState.fontSize.fontSize,
),
linkStyle: TextStyle(
fontSize: MediaQuery.of(context)
.textScaleFactor *
prefState.fontSize.fontSize,
color: Palette.orange,
),
onOpen: (LinkableElement link) {
if (link.url.isStoryLink) {
onStoryLinkTapped.call(link.url);
} else {
LinkUtil.launch(link.url);
}
},
onTap: () => onTextTapped(context),
), ),
child: comment is BuildableComment
? SelectableText.rich(
key: ValueKey<int>(comment.id),
buildTextSpan(
(comment as BuildableComment)
.elements,
style: TextStyle(
fontSize: MediaQuery.of(
context,
).textScaleFactor *
prefState.fontSize.fontSize,
),
linkStyle: TextStyle(
fontSize: MediaQuery.of(
context,
).textScaleFactor *
prefState.fontSize.fontSize,
decoration:
TextDecoration.underline,
color: Palette.orange,
),
onOpen: (LinkableElement link) {
if (link.url.isStoryLink) {
onStoryLinkTapped
.call(link.url);
} else {
LinkUtil.launch(link.url);
}
},
),
onTap: () => onTextTapped(context),
)
: SelectableLinkify(
key: ValueKey<int>(comment.id),
text: comment.text,
style: TextStyle(
fontSize: MediaQuery.of(context)
.textScaleFactor *
prefState.fontSize.fontSize,
),
linkStyle: TextStyle(
fontSize: MediaQuery.of(context)
.textScaleFactor *
prefState.fontSize.fontSize,
color: Palette.orange,
),
onOpen: (LinkableElement link) {
if (link.url.isStoryLink) {
onStoryLinkTapped.call(link.url);
} else {
LinkUtil.launch(link.url);
}
},
onTap: () => onTextTapped(context),
),
),
],
), ),
if (!state.collapsed && ),
fetchMode == FetchMode.lazy && if (_shouldShowLoadButton(context))
comment.kids.isNotEmpty &&
!context
.read<CommentsCubit>()
.state
.commentIds
.contains(comment.kids.first) &&
!context
.read<CommentsCubit>()
.state
.onlyShowTargetComment)
Center( Center(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@ -376,7 +335,12 @@ class CommentTile extends StatelessWidget {
); );
} }
static final Map<int, Color> _colors = <int, Color>{}; void onTextTapped(BuildContext context) {
if (context.read<PreferenceCubit>().state.tapAnywhereToCollapseEnabled) {
HapticFeedback.selectionClick();
context.read<CollapseCubit>().collapse();
}
}
Color _getColor(int level) { Color _getColor(int level) {
final int initialLevel = level; final int initialLevel = level;
@ -406,10 +370,13 @@ class CommentTile extends StatelessWidget {
return color; return color;
} }
void onTextTapped(BuildContext context) { bool _shouldShowLoadButton(BuildContext context) {
if (context.read<PreferenceCubit>().state.tapAnywhereToCollapseEnabled) { final CollapseState collapseState = context.read<CollapseCubit>().state;
HapticFeedback.selectionClick(); final CommentsState commentsState = context.read<CommentsCubit>().state;
context.read<CollapseCubit>().collapse(); return fetchMode == FetchMode.lazy &&
} comment.kids.isNotEmpty &&
collapseState.collapsed == false &&
commentsState.commentIds.contains(comment.kids.first) == false &&
commentsState.onlyShowTargetComment == false;
} }
} }

View File

@ -111,7 +111,7 @@ class _CountDownReminderState extends State<CountdownReminder>
.fetchStoryBy(state.storyId!) .fetchStoryBy(state.storyId!)
.then((Story? story) { .then((Story? story) {
if (story == null) { if (story == null) {
showSnackBar(content: 'Something went wrong...'); showErrorSnackBar();
return; return;
} }
final ItemScreenArgs args = ItemScreenArgs(item: story); final ItemScreenArgs args = ItemScreenArgs(item: story);

View File

@ -5,7 +5,7 @@ import 'package:hacki/config/constants.dart';
import 'package:hacki/extensions/extensions.dart'; import 'package:hacki/extensions/extensions.dart';
import 'package:hacki/models/models.dart'; import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/link_preview/link_view.dart'; import 'package:hacki/screens/widgets/link_preview/link_view.dart';
import 'package:hacki/screens/widgets/link_preview/web_analyzer.dart'; import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
@ -119,7 +119,7 @@ class _LinkPreviewState extends State<LinkPreview> {
@override @override
void initState() { void initState() {
_errorTitle = widget.errorTitle ?? 'Something went wrong!'; _errorTitle = widget.errorTitle ?? Constants.errorMessage;
_errorBody = widget.errorBody ?? _errorBody = widget.errorBody ??
'Oops! Unable to parse the url. We have ' 'Oops! Unable to parse the url. We have '
'sent feedback to our developers & ' 'sent feedback to our developers & '

View File

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hacki/blocs/blocs.dart'; import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/cubits/cubits.dart'; import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/screens/widgets/link_preview/web_analyzer.dart'; import 'package:hacki/services/services.dart';
import 'package:hacki/styles/styles.dart'; import 'package:hacki/styles/styles.dart';
class OfflineBanner extends StatelessWidget { class OfflineBanner extends StatelessWidget {

View File

@ -1,4 +1,5 @@
export 'bloc_builder_3.dart'; export 'bloc_builder_3.dart';
export 'centered_text.dart';
export 'circle_tab_indicator.dart'; export 'circle_tab_indicator.dart';
export 'comment_tile.dart'; export 'comment_tile.dart';
export 'countdown_reminder.dart'; export 'countdown_reminder.dart';

View File

@ -3,3 +3,4 @@ export 'custom_bloc_observer.dart';
export 'fetcher.dart'; export 'fetcher.dart';
export 'firebase_client.dart'; export 'firebase_client.dart';
export 'local_notification.dart'; export 'local_notification.dart';
export 'web_analyzer.dart';

View File

@ -14,7 +14,7 @@ abstract class LogUtil {
colors: false, colors: false,
); );
static LogOutput getLogOutput(File outputFile) => MultiOutput( static LogOutput logOutput(File outputFile) => MultiOutput(
<LogOutput>[ <LogOutput>[
ConsoleOutput(), ConsoleOutput(),
CustomFileOutput( CustomFileOutput(
@ -43,7 +43,7 @@ abstract class LogUtil {
final Uint8List fileContent = await currentSessionLog.readAsBytes(); final Uint8List fileContent = await currentSessionLog.readAsBytes();
await previousSessionLog.writeAsString( await previousSessionLog.writeAsString(
'Current session logs:', 'Current session logs:\n',
mode: FileMode.append, mode: FileMode.append,
); );
return previousSessionLog.writeAsBytes( return previousSessionLog.writeAsBytes(

View File

@ -1358,5 +1358,5 @@ packages:
source: hosted source: hosted
version: "3.1.1" version: "3.1.1"
sdks: sdks:
dart: ">=2.18.0 <4.0.0" dart: ">=2.18.0 <3.0.0"
flutter: ">=3.7.0" flutter: ">=3.7.3"

View File

@ -1,11 +1,11 @@
name: hacki name: hacki
description: A Hacker News reader. description: A Hacker News reader.
version: 1.0.5+83 version: 1.0.10+88
publish_to: none publish_to: none
environment: environment:
sdk: ">=2.17.0 <3.0.0" sdk: ">=2.17.0 <3.0.0"
flutter: "3.7.0" flutter: "3.7.3"
dependencies: dependencies:
adaptive_theme: ^3.0.0 adaptive_theme: ^3.0.0