mirror of
https://github.com/Livinglist/Hacki.git
synced 2025-08-14 18:46:41 +08:00
Compare commits
11 Commits
Author | SHA1 | Date | |
---|---|---|---|
8348a87a75 | |||
58837b6c00 | |||
8365869ee8 | |||
5c70185236 | |||
e4a385deb7 | |||
dfde6a74eb | |||
a2223dc531 | |||
f75e6a5e3b | |||
a87d521d32 | |||
94d76d4c20 | |||
1176e3bb80 |
@ -1,6 +1,6 @@
|
|||||||
# Hacki
|
# Hacki for Hacker News
|
||||||
|
|
||||||
A simple Hacker News reader made with Flutter.
|
A simple noiseless Hacker News reader made with Flutter that is just enough.
|
||||||
|
|
||||||

|

|
||||||
[](https://apps.apple.com/us/app/hacki/id1602043763)
|
[](https://apps.apple.com/us/app/hacki/id1602043763)
|
||||||
|
1
fastlane/metadata/android/en-US/changelogs/26.txt
Normal file
1
fastlane/metadata/android/en-US/changelogs/26.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
- Tapping on comments in notification and history screen will lead you directly to the comment.
|
@ -356,7 +356,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 4;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = QMWX3X2NF7;
|
DEVELOPMENT_TEAM = QMWX3X2NF7;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@ -365,7 +365,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.1.8;
|
MARKETING_VERSION = 0.1.9;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
|
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@ -491,7 +491,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 4;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = QMWX3X2NF7;
|
DEVELOPMENT_TEAM = QMWX3X2NF7;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@ -500,7 +500,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.1.8;
|
MARKETING_VERSION = 0.1.9;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
|
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
@ -520,7 +520,7 @@
|
|||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 4;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = QMWX3X2NF7;
|
DEVELOPMENT_TEAM = QMWX3X2NF7;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
@ -529,7 +529,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.1.8;
|
MARKETING_VERSION = 0.1.9;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
|
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||||
|
@ -8,21 +8,31 @@ import 'package:hacki/services/cache_service.dart';
|
|||||||
part 'comments_state.dart';
|
part 'comments_state.dart';
|
||||||
|
|
||||||
class CommentsCubit<T extends Item> extends Cubit<CommentsState> {
|
class CommentsCubit<T extends Item> extends Cubit<CommentsState> {
|
||||||
CommentsCubit(
|
CommentsCubit({
|
||||||
{required T item,
|
|
||||||
CacheService? cacheService,
|
CacheService? cacheService,
|
||||||
StoriesRepository? storiesRepository})
|
StoriesRepository? storiesRepository,
|
||||||
: _cacheService = cacheService ?? locator.get<CacheService>(),
|
}) : _cacheService = cacheService ?? locator.get<CacheService>(),
|
||||||
_storiesRepository =
|
_storiesRepository =
|
||||||
storiesRepository ?? locator.get<StoriesRepository>(),
|
storiesRepository ?? locator.get<StoriesRepository>(),
|
||||||
super(CommentsState.init()) {
|
super(CommentsState.init());
|
||||||
init(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
final CacheService _cacheService;
|
final CacheService _cacheService;
|
||||||
final StoriesRepository _storiesRepository;
|
final StoriesRepository _storiesRepository;
|
||||||
|
|
||||||
Future<void> init(T item) async {
|
Future<void> init(
|
||||||
|
T item, {
|
||||||
|
bool onlyShowTargetComment = false,
|
||||||
|
Comment? targetComment,
|
||||||
|
}) async {
|
||||||
|
if (onlyShowTargetComment) {
|
||||||
|
emit(state.copyWith(
|
||||||
|
item: item,
|
||||||
|
comments: targetComment != null ? [targetComment] : [],
|
||||||
|
onlyShowTargetComment: true,
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (item is Story) {
|
if (item is Story) {
|
||||||
final story = item;
|
final story = item;
|
||||||
final updatedStory = await _storiesRepository.fetchStoryById(story.id);
|
final updatedStory = await _storiesRepository.fetchStoryById(story.id);
|
||||||
@ -97,6 +107,14 @@ class CommentsCubit<T extends Item> extends Cubit<CommentsState> {
|
|||||||
emit(state.copyWith(collapsed: !state.collapsed));
|
emit(state.copyWith(collapsed: !state.collapsed));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void loadAll(T item) {
|
||||||
|
emit(state.copyWith(
|
||||||
|
onlyShowTargetComment: false,
|
||||||
|
comments: [],
|
||||||
|
));
|
||||||
|
init(item);
|
||||||
|
}
|
||||||
|
|
||||||
void _onCommentFetched(Comment? comment) {
|
void _onCommentFetched(Comment? comment) {
|
||||||
if (comment != null) {
|
if (comment != null) {
|
||||||
_cacheService.cacheComment(comment);
|
_cacheService.cacheComment(comment);
|
||||||
|
@ -13,30 +13,36 @@ class CommentsState extends Equatable {
|
|||||||
required this.comments,
|
required this.comments,
|
||||||
required this.status,
|
required this.status,
|
||||||
required this.collapsed,
|
required this.collapsed,
|
||||||
|
required this.onlyShowTargetComment,
|
||||||
});
|
});
|
||||||
|
|
||||||
CommentsState.init()
|
CommentsState.init()
|
||||||
: item = null,
|
: item = null,
|
||||||
comments = [],
|
comments = [],
|
||||||
status = CommentsStatus.init,
|
status = CommentsStatus.init,
|
||||||
collapsed = false;
|
collapsed = false,
|
||||||
|
onlyShowTargetComment = false;
|
||||||
|
|
||||||
final Item? item;
|
final Item? item;
|
||||||
final List<Comment> comments;
|
final List<Comment> comments;
|
||||||
final CommentsStatus status;
|
final CommentsStatus status;
|
||||||
final bool collapsed;
|
final bool collapsed;
|
||||||
|
final bool onlyShowTargetComment;
|
||||||
|
|
||||||
CommentsState copyWith({
|
CommentsState copyWith({
|
||||||
Item? item,
|
Item? item,
|
||||||
List<Comment>? comments,
|
List<Comment>? comments,
|
||||||
CommentsStatus? status,
|
CommentsStatus? status,
|
||||||
bool? collapsed,
|
bool? collapsed,
|
||||||
|
bool? onlyShowTargetComment,
|
||||||
}) {
|
}) {
|
||||||
return CommentsState(
|
return CommentsState(
|
||||||
item: item ?? this.item,
|
item: item ?? this.item,
|
||||||
comments: comments ?? this.comments,
|
comments: comments ?? this.comments,
|
||||||
status: status ?? this.status,
|
status: status ?? this.status,
|
||||||
collapsed: collapsed ?? this.collapsed,
|
collapsed: collapsed ?? this.collapsed,
|
||||||
|
onlyShowTargetComment:
|
||||||
|
onlyShowTargetComment ?? this.onlyShowTargetComment,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -46,5 +52,6 @@ class CommentsState extends Equatable {
|
|||||||
comments,
|
comments,
|
||||||
status,
|
status,
|
||||||
collapsed,
|
collapsed,
|
||||||
|
onlyShowTargetComment,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
@ -3,7 +3,7 @@ import 'dart:math';
|
|||||||
|
|
||||||
import 'package:bloc/bloc.dart';
|
import 'package:bloc/bloc.dart';
|
||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
import 'package:hacki/blocs/auth/auth_bloc.dart';
|
import 'package:hacki/blocs/blocs.dart';
|
||||||
import 'package:hacki/config/locator.dart';
|
import 'package:hacki/config/locator.dart';
|
||||||
import 'package:hacki/cubits/cubits.dart';
|
import 'package:hacki/cubits/cubits.dart';
|
||||||
import 'package:hacki/models/models.dart';
|
import 'package:hacki/models/models.dart';
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import 'package:firebase/firebase_io.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:hacki/models/models.dart';
|
import 'package:hacki/models/models.dart';
|
||||||
|
import 'package:hacki/services/services.dart';
|
||||||
import 'package:html_unescape/html_unescape.dart';
|
import 'package:html_unescape/html_unescape.dart';
|
||||||
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
class StoriesRepository {
|
class StoriesRepository {
|
||||||
StoriesRepository({
|
StoriesRepository({
|
||||||
@ -128,7 +129,7 @@ class StoriesRepository {
|
|||||||
Future<Item?> fetchItemBy({required int id}) async {
|
Future<Item?> fetchItemBy({required int id}) async {
|
||||||
final item = await _firebaseClient
|
final item = await _firebaseClient
|
||||||
.get('${_baseUrl}item/$id.json')
|
.get('${_baseUrl}item/$id.json')
|
||||||
.then((dynamic val) {
|
.then((dynamic val) async {
|
||||||
if (val == null) {
|
if (val == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@ -138,6 +139,9 @@ class StoriesRepository {
|
|||||||
final story = Story.fromJson(json);
|
final story = Story.fromJson(json);
|
||||||
return story;
|
return story;
|
||||||
} else if (json['type'] == 'comment') {
|
} else if (json['type'] == 'comment') {
|
||||||
|
final text = json['text'] as String? ?? '';
|
||||||
|
final parsedText = await compute<String, String>(_parseHtml, text);
|
||||||
|
json['text'] = parsedText;
|
||||||
final comment = Comment.fromJson(json);
|
final comment = Comment.fromJson(json);
|
||||||
return comment;
|
return comment;
|
||||||
}
|
}
|
||||||
@ -172,6 +176,22 @@ class StoriesRepository {
|
|||||||
return item as Story;
|
return item as Story;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<Tuple2<Story, List<Comment>>?> fetchParentStoryWithComments(
|
||||||
|
{required int id}) async {
|
||||||
|
Item? item;
|
||||||
|
final parentComments = <Comment>[];
|
||||||
|
|
||||||
|
do {
|
||||||
|
item = await fetchItemBy(id: item?.parent ?? id);
|
||||||
|
if (item is Comment) {
|
||||||
|
parentComments.add(item);
|
||||||
|
}
|
||||||
|
if (item == null) return null;
|
||||||
|
} while (item is Comment);
|
||||||
|
|
||||||
|
return Tuple2<Story, List<Comment>>(item as Story, parentComments);
|
||||||
|
}
|
||||||
|
|
||||||
static String _parseHtml(String text) {
|
static String _parseHtml(String text) {
|
||||||
return HtmlUnescape()
|
return HtmlUnescape()
|
||||||
.convert(text)
|
.convert(text)
|
||||||
|
@ -248,9 +248,7 @@ class _HomeScreenState extends State<HomeScreen>
|
|||||||
'to check out stories and comments you have '
|
'to check out stories and comments you have '
|
||||||
'posted in the past, and get in-app '
|
'posted in the past, and get in-app '
|
||||||
'notification when there is new reply to '
|
'notification when there is new reply to '
|
||||||
'your comments or stories.\n\nAlso, you can '
|
'your comments or stories.',
|
||||||
'long press here to submit a new link to '
|
|
||||||
'Hacker News.',
|
|
||||||
style: TextStyle(fontSize: 16),
|
style: TextStyle(fontSize: 16),
|
||||||
),
|
),
|
||||||
child: BlocBuilder<NotificationCubit,
|
child: BlocBuilder<NotificationCubit,
|
||||||
|
@ -40,6 +40,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
final refreshControllerFav = RefreshController();
|
final refreshControllerFav = RefreshController();
|
||||||
final refreshControllerNotification = RefreshController();
|
final refreshControllerNotification = RefreshController();
|
||||||
final scrollController = ScrollController();
|
final scrollController = ScrollController();
|
||||||
|
final throttle = Throttle(delay: const Duration(seconds: 2));
|
||||||
|
|
||||||
_PageType pageType = _PageType.notification;
|
_PageType pageType = _PageType.notification;
|
||||||
|
|
||||||
@ -52,6 +53,16 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
'to infinity and beyond!',
|
'to infinity and beyond!',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
super.dispose();
|
||||||
|
refreshControllerHistory.dispose();
|
||||||
|
refreshControllerFav.dispose();
|
||||||
|
refreshControllerNotification.dispose();
|
||||||
|
scrollController.dispose();
|
||||||
|
throttle.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context);
|
super.build(context);
|
||||||
@ -86,8 +97,9 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
builder: (context, historyState) {
|
builder: (context, historyState) {
|
||||||
if (!authState.isLoggedIn ||
|
if ((!authState.isLoggedIn ||
|
||||||
historyState.submittedItems.isEmpty) {
|
historyState.submittedItems.isEmpty) &&
|
||||||
|
historyState.status != HistoryStatus.loading) {
|
||||||
return const _CenteredMessageView(
|
return const _CenteredMessageView(
|
||||||
content: 'Your past comments and stories will '
|
content: 'Your past comments and stories will '
|
||||||
'show up here.',
|
'show up here.',
|
||||||
@ -96,6 +108,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
|
|
||||||
return ItemsListView<Item>(
|
return ItemsListView<Item>(
|
||||||
showWebPreview: false,
|
showWebPreview: false,
|
||||||
|
useConsistentFontSize: true,
|
||||||
refreshController: refreshControllerHistory,
|
refreshController: refreshControllerHistory,
|
||||||
items: historyState.submittedItems
|
items: historyState.submittedItems
|
||||||
.where((e) => !e.dead && !e.deleted)
|
.where((e) => !e.dead && !e.deleted)
|
||||||
@ -113,17 +126,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
StoryScreen.routeName,
|
StoryScreen.routeName,
|
||||||
arguments: StoryScreenArgs(story: item));
|
arguments: StoryScreenArgs(story: item));
|
||||||
} else if (item is Comment) {
|
} else if (item is Comment) {
|
||||||
locator
|
onCommentTapped(item);
|
||||||
.get<StoriesRepository>()
|
|
||||||
.fetchParentStory(id: item.parent)
|
|
||||||
.then((story) {
|
|
||||||
if (story != null && mounted) {
|
|
||||||
HackiApp.navigatorKey.currentState!
|
|
||||||
.pushNamed(StoryScreen.routeName,
|
|
||||||
arguments: StoryScreenArgs(
|
|
||||||
story: story));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -144,7 +147,8 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
builder: (context, favState) {
|
builder: (context, favState) {
|
||||||
if (favState.favStories.isEmpty) {
|
if (favState.favStories.isEmpty &&
|
||||||
|
favState.status != FavStatus.loading) {
|
||||||
return const _CenteredMessageView(
|
return const _CenteredMessageView(
|
||||||
content:
|
content:
|
||||||
'Your favorite stories will show up here.'
|
'Your favorite stories will show up here.'
|
||||||
@ -190,22 +194,9 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
unreadCommentsIds:
|
unreadCommentsIds:
|
||||||
notificationState.unreadCommentsIds,
|
notificationState.unreadCommentsIds,
|
||||||
comments: notificationState.comments,
|
comments: notificationState.comments,
|
||||||
onCommentTapped: (comment) {
|
onCommentTapped: (cmt) {
|
||||||
locator
|
onCommentTapped(cmt, then: () {
|
||||||
.get<StoriesRepository>()
|
context.read<NotificationCubit>().markAsRead(cmt);
|
||||||
.fetchParentStory(id: comment.parent)
|
|
||||||
.then((story) {
|
|
||||||
if (story != null && mounted) {
|
|
||||||
context
|
|
||||||
.read<NotificationCubit>()
|
|
||||||
.markAsRead(comment);
|
|
||||||
HackiApp.navigatorKey.currentState!.pushNamed(
|
|
||||||
StoryScreen.routeName,
|
|
||||||
arguments: StoryScreenArgs(
|
|
||||||
story: story,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onMarkAllAsReadTapped: () {
|
onMarkAllAsReadTapped: () {
|
||||||
@ -381,7 +372,7 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
showAboutDialog(
|
showAboutDialog(
|
||||||
context: context,
|
context: context,
|
||||||
applicationName: 'Hacki',
|
applicationName: 'Hacki',
|
||||||
applicationVersion: 'v0.1.8',
|
applicationVersion: 'v0.1.9',
|
||||||
applicationIcon: Image.asset(
|
applicationIcon: Image.asset(
|
||||||
Constants.hackiIconPath,
|
Constants.hackiIconPath,
|
||||||
height: 50,
|
height: 50,
|
||||||
@ -595,6 +586,30 @@ class _ProfileScreenState extends State<ProfileScreen>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void onCommentTapped(Comment comment, {VoidCallback? then}) {
|
||||||
|
throttle.run(() {
|
||||||
|
locator
|
||||||
|
.get<StoriesRepository>()
|
||||||
|
.fetchParentStoryWithComments(id: comment.parent)
|
||||||
|
.then((tuple) {
|
||||||
|
if (tuple != null && mounted) {
|
||||||
|
HackiApp.navigatorKey.currentState!
|
||||||
|
.pushNamed(
|
||||||
|
StoryScreen.routeName,
|
||||||
|
arguments: StoryScreenArgs(
|
||||||
|
story: tuple.item1,
|
||||||
|
targetComments: tuple.item2.isEmpty
|
||||||
|
? [comment]
|
||||||
|
: [comment, ...tuple.item2],
|
||||||
|
onlyShowTargetComment: true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.then((_) => then?.call());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
void onLoginTapped() {
|
void onLoginTapped() {
|
||||||
final usernameController = TextEditingController();
|
final usernameController = TextEditingController();
|
||||||
final passwordController = TextEditingController();
|
final passwordController = TextEditingController();
|
||||||
|
@ -31,13 +31,21 @@ enum _MenuAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class StoryScreenArgs {
|
class StoryScreenArgs {
|
||||||
StoryScreenArgs({required this.story});
|
StoryScreenArgs({
|
||||||
|
required this.story,
|
||||||
|
this.onlyShowTargetComment = false,
|
||||||
|
this.targetComments,
|
||||||
|
});
|
||||||
|
|
||||||
final Story story;
|
final Story story;
|
||||||
|
final bool onlyShowTargetComment;
|
||||||
|
final List<Comment>? targetComments;
|
||||||
}
|
}
|
||||||
|
|
||||||
class StoryScreen extends StatefulWidget {
|
class StoryScreen extends StatefulWidget {
|
||||||
const StoryScreen({Key? key, required this.story}) : super(key: key);
|
const StoryScreen(
|
||||||
|
{Key? key, required this.story, required this.parentComments})
|
||||||
|
: super(key: key);
|
||||||
|
|
||||||
static const String routeName = '/story';
|
static const String routeName = '/story';
|
||||||
|
|
||||||
@ -50,8 +58,11 @@ class StoryScreen extends StatefulWidget {
|
|||||||
create: (context) => PostCubit(),
|
create: (context) => PostCubit(),
|
||||||
),
|
),
|
||||||
BlocProvider<CommentsCubit>(
|
BlocProvider<CommentsCubit>(
|
||||||
create: (_) => CommentsCubit<Story>(
|
create: (_) => CommentsCubit<Story>()
|
||||||
item: args.story,
|
..init(
|
||||||
|
args.story,
|
||||||
|
onlyShowTargetComment: args.onlyShowTargetComment,
|
||||||
|
targetComment: args.targetComments?.last,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
BlocProvider<EditCubit>(
|
BlocProvider<EditCubit>(
|
||||||
@ -60,12 +71,14 @@ class StoryScreen extends StatefulWidget {
|
|||||||
],
|
],
|
||||||
child: StoryScreen(
|
child: StoryScreen(
|
||||||
story: args.story,
|
story: args.story,
|
||||||
|
parentComments: args.targetComments ?? [],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final Story story;
|
final Story story;
|
||||||
|
final List<Comment> parentComments;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_StoryScreenState createState() => _StoryScreenState();
|
_StoryScreenState createState() => _StoryScreenState();
|
||||||
@ -338,7 +351,8 @@ class _StoryScreenState extends State<StoryScreen> {
|
|||||||
),
|
),
|
||||||
body: SmartRefresher(
|
body: SmartRefresher(
|
||||||
scrollController: scrollController,
|
scrollController: scrollController,
|
||||||
enablePullUp: true,
|
enablePullUp: !state.onlyShowTargetComment,
|
||||||
|
enablePullDown: !state.onlyShowTargetComment,
|
||||||
header: WaterDropMaterialHeader(
|
header: WaterDropMaterialHeader(
|
||||||
backgroundColor: Colors.orange,
|
backgroundColor: Colors.orange,
|
||||||
offset: topPadding,
|
offset: topPadding,
|
||||||
@ -458,17 +472,37 @@ class _StoryScreenState extends State<StoryScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (widget.story.text.isNotEmpty)
|
if (widget.story.text.isNotEmpty)
|
||||||
Html(
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
),
|
||||||
|
child: SelectableHtml(
|
||||||
data: widget.story.text,
|
data: widget.story.text,
|
||||||
onLinkTap: (link, _, __, ___) =>
|
onLinkTap: (link, _, __, ___) =>
|
||||||
LinkUtil.launchUrl(link ?? ''),
|
LinkUtil.launchUrl(link ?? ''),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (widget.story.text.isNotEmpty)
|
||||||
|
const SizedBox(
|
||||||
|
height: 8,
|
||||||
|
),
|
||||||
const Divider(
|
const Divider(
|
||||||
height: 0,
|
height: 0,
|
||||||
),
|
),
|
||||||
|
if (state.onlyShowTargetComment) ...[
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => context
|
||||||
|
.read<CommentsCubit>()
|
||||||
|
.loadAll(widget.story),
|
||||||
|
child: const Text('View all comments'),
|
||||||
|
),
|
||||||
|
const Divider(
|
||||||
|
height: 0,
|
||||||
|
),
|
||||||
|
],
|
||||||
if (state.comments.isEmpty &&
|
if (state.comments.isEmpty &&
|
||||||
state.status == CommentsStatus.loaded) ...[
|
state.status == CommentsStatus.loaded) ...[
|
||||||
const SizedBox(
|
const SizedBox(
|
||||||
@ -485,6 +519,11 @@ class _StoryScreenState extends State<StoryScreen> {
|
|||||||
(e) => FadeIn(
|
(e) => FadeIn(
|
||||||
child: CommentTile(
|
child: CommentTile(
|
||||||
comment: e,
|
comment: e,
|
||||||
|
onlyShowTargetComment:
|
||||||
|
state.onlyShowTargetComment,
|
||||||
|
targetComments: widget.parentComments.sublist(
|
||||||
|
0,
|
||||||
|
max(widget.parentComments.length - 1, 0)),
|
||||||
myUsername: authState.isLoggedIn
|
myUsername: authState.isLoggedIn
|
||||||
? authState.username
|
? authState.username
|
||||||
: null,
|
: null,
|
||||||
|
@ -76,7 +76,8 @@ class _ReplyBoxState extends State<ReplyBox> {
|
|||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
if (!widget.isLoading) ...[
|
if (!widget.isLoading) ...[
|
||||||
if (widget.replyingTo != null) ...[
|
...[
|
||||||
|
if (widget.replyingTo != null)
|
||||||
AnimatedOpacity(
|
AnimatedOpacity(
|
||||||
opacity: expanded ? 1 : 0,
|
opacity: expanded ? 1 : 0,
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
|
@ -1,10 +1,12 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_fadein/flutter_fadein.dart';
|
import 'package:flutter_fadein/flutter_fadein.dart';
|
||||||
import 'package:flutter_linkify/flutter_linkify.dart';
|
import 'package:flutter_linkify/flutter_linkify.dart';
|
||||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||||
import 'package:hacki/blocs/auth/auth_bloc.dart';
|
import 'package:hacki/blocs/blocs.dart';
|
||||||
import 'package:hacki/cubits/cubits.dart';
|
import 'package:hacki/cubits/cubits.dart';
|
||||||
import 'package:hacki/models/models.dart';
|
import 'package:hacki/models/models.dart';
|
||||||
import 'package:hacki/utils/utils.dart';
|
import 'package:hacki/utils/utils.dart';
|
||||||
@ -19,22 +21,31 @@ class CommentTile extends StatelessWidget {
|
|||||||
required this.onEditTapped,
|
required this.onEditTapped,
|
||||||
required this.onStoryLinkTapped,
|
required this.onStoryLinkTapped,
|
||||||
this.loadKids = true,
|
this.loadKids = true,
|
||||||
|
this.onlyShowTargetComment = false,
|
||||||
this.level = 0,
|
this.level = 0,
|
||||||
|
this.targetComments = const [],
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
final String? myUsername;
|
final String? myUsername;
|
||||||
final Comment comment;
|
final Comment comment;
|
||||||
final int level;
|
final int level;
|
||||||
final bool loadKids;
|
final bool loadKids;
|
||||||
|
final bool onlyShowTargetComment;
|
||||||
final Function(Comment) onReplyTapped;
|
final Function(Comment) onReplyTapped;
|
||||||
final Function(Comment) onMoreTapped;
|
final Function(Comment) onMoreTapped;
|
||||||
final Function(Comment) onEditTapped;
|
final Function(Comment) onEditTapped;
|
||||||
final Function(String) onStoryLinkTapped;
|
final Function(String) onStoryLinkTapped;
|
||||||
|
final List<Comment> targetComments;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocProvider<CommentsCubit>(
|
return BlocProvider<CommentsCubit>(
|
||||||
create: (_) => CommentsCubit<Comment>(item: comment),
|
lazy: false,
|
||||||
|
create: (_) => CommentsCubit<Comment>()
|
||||||
|
..init(comment,
|
||||||
|
onlyShowTargetComment: onlyShowTargetComment,
|
||||||
|
targetComment:
|
||||||
|
targetComments.isNotEmpty ? targetComments.last : null),
|
||||||
child: BlocBuilder<CommentsCubit, CommentsState>(
|
child: BlocBuilder<CommentsCubit, CommentsState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
return BlocBuilder<PreferenceCubit, PreferenceState>(
|
return BlocBuilder<PreferenceCubit, PreferenceState>(
|
||||||
@ -201,6 +212,16 @@ class CommentTile extends StatelessWidget {
|
|||||||
(e) => FadeIn(
|
(e) => FadeIn(
|
||||||
child: CommentTile(
|
child: CommentTile(
|
||||||
comment: e,
|
comment: e,
|
||||||
|
onlyShowTargetComment:
|
||||||
|
onlyShowTargetComment &&
|
||||||
|
targetComments.length > 1,
|
||||||
|
targetComments: targetComments
|
||||||
|
.isNotEmpty
|
||||||
|
? targetComments.sublist(
|
||||||
|
0,
|
||||||
|
max(targetComments.length - 1,
|
||||||
|
0))
|
||||||
|
: [],
|
||||||
myUsername: myUsername,
|
myUsername: myUsername,
|
||||||
onReplyTapped: onReplyTapped,
|
onReplyTapped: onReplyTapped,
|
||||||
onMoreTapped: onMoreTapped,
|
onMoreTapped: onMoreTapped,
|
||||||
|
@ -6,8 +6,7 @@ import 'package:flutter_linkify/flutter_linkify.dart';
|
|||||||
import 'package:flutter_slidable/flutter_slidable.dart';
|
import 'package:flutter_slidable/flutter_slidable.dart';
|
||||||
import 'package:hacki/cubits/cubits.dart';
|
import 'package:hacki/cubits/cubits.dart';
|
||||||
import 'package:hacki/models/models.dart';
|
import 'package:hacki/models/models.dart';
|
||||||
import 'package:hacki/screens/widgets/custom_circular_progress_indicator.dart';
|
import 'package:hacki/screens/widgets/widgets.dart';
|
||||||
import 'package:hacki/screens/widgets/story_tile.dart';
|
|
||||||
import 'package:hacki/utils/utils.dart';
|
import 'package:hacki/utils/utils.dart';
|
||||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||||
|
|
||||||
@ -21,6 +20,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
|||||||
this.enablePullDown = true,
|
this.enablePullDown = true,
|
||||||
this.pinnable = false,
|
this.pinnable = false,
|
||||||
this.markReadStories = false,
|
this.markReadStories = false,
|
||||||
|
this.useConsistentFontSize = false,
|
||||||
this.onRefresh,
|
this.onRefresh,
|
||||||
this.onLoadMore,
|
this.onLoadMore,
|
||||||
this.onPinned,
|
this.onPinned,
|
||||||
@ -34,6 +34,10 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
|||||||
|
|
||||||
/// Whether story tiles can be pinned to the top.
|
/// Whether story tiles can be pinned to the top.
|
||||||
final bool pinnable;
|
final bool pinnable;
|
||||||
|
|
||||||
|
/// Whether to use same font size for comment and story tiles.
|
||||||
|
final bool useConsistentFontSize;
|
||||||
|
|
||||||
final List<T> items;
|
final List<T> items;
|
||||||
final Widget? header;
|
final Widget? header;
|
||||||
final RefreshController? refreshController;
|
final RefreshController? refreshController;
|
||||||
@ -79,6 +83,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
|
|||||||
onTap: () => onTap(e),
|
onTap: () => onTap(e),
|
||||||
showWebPreview: showWebPreview,
|
showWebPreview: showWebPreview,
|
||||||
wasRead: markReadStories && wasRead,
|
wasRead: markReadStories && wasRead,
|
||||||
|
simpleTileFontSize: useConsistentFontSize ? 14 : 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -14,12 +14,14 @@ class StoryTile extends StatelessWidget {
|
|||||||
required this.showWebPreview,
|
required this.showWebPreview,
|
||||||
required this.story,
|
required this.story,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
|
this.simpleTileFontSize = 16,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
final bool showWebPreview;
|
final bool showWebPreview;
|
||||||
final bool wasRead;
|
final bool wasRead;
|
||||||
final Story story;
|
final Story story;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
final double simpleTileFontSize;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -178,7 +180,7 @@ class StoryTile extends StatelessWidget {
|
|||||||
story.title,
|
story.title,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: wasRead ? Colors.grey[500] : null,
|
color: wasRead ? Colors.grey[500] : null,
|
||||||
fontSize: 16,
|
fontSize: simpleTileFontSize,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
119
lib/services/firebase_client.dart
Normal file
119
lib/services/firebase_client.dart
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:http/http.dart';
|
||||||
|
|
||||||
|
/// FirebaseClient wraps a REST client for a Firebase realtime database.
|
||||||
|
///
|
||||||
|
/// The client supports authentication and GET, PUT, POST, DELETE
|
||||||
|
/// and PATCH methods.
|
||||||
|
class FirebaseClient {
|
||||||
|
/// Creates a new FirebaseClient with [credential] and optional [client].
|
||||||
|
///
|
||||||
|
/// For credential you can either use Firebase app's secret or
|
||||||
|
/// an authentication token.
|
||||||
|
/// See: <https://firebase.google.com/docs/reference/rest/database/user-auth>.
|
||||||
|
FirebaseClient(this.credential, {Client? client})
|
||||||
|
: _client = client ?? Client();
|
||||||
|
|
||||||
|
/// Creates a new anonymous FirebaseClient with optional [client].
|
||||||
|
FirebaseClient.anonymous({Client? client})
|
||||||
|
: credential = null,
|
||||||
|
_client = client ?? Client();
|
||||||
|
|
||||||
|
/// Auth credential.
|
||||||
|
final String? credential;
|
||||||
|
final Client _client;
|
||||||
|
|
||||||
|
/// Reads data from database using a HTTP GET request.
|
||||||
|
/// The response from a successful request contains a data being retrieved.
|
||||||
|
///
|
||||||
|
/// See: <https://firebase.google.com/docs/reference/rest/database/#section-get>.
|
||||||
|
Future<dynamic> get(dynamic uri) => send('GET', uri);
|
||||||
|
|
||||||
|
/// Writes or replaces data in database using a HTTP PUT request.
|
||||||
|
/// The response from a successful request contains a data being written.
|
||||||
|
///
|
||||||
|
/// See: <https://firebase.google.com/docs/reference/rest/database/#section-put>.
|
||||||
|
Future<dynamic> put(dynamic uri, dynamic json) =>
|
||||||
|
send('PUT', uri, json: json);
|
||||||
|
|
||||||
|
/// Pushes data to database using a HTTP POST request.
|
||||||
|
/// The response from a successful request contains a key of the new data
|
||||||
|
/// being added.
|
||||||
|
///
|
||||||
|
/// See: <https://firebase.google.com/docs/reference/rest/database/#section-post>.
|
||||||
|
Future<dynamic> post(dynamic uri, dynamic json) =>
|
||||||
|
send('POST', uri, json: json);
|
||||||
|
|
||||||
|
/// Updates specific children at a location without overwriting existing data
|
||||||
|
/// using a HTTP PATCH request.
|
||||||
|
/// The response from a successful request contains a data being written.
|
||||||
|
///
|
||||||
|
/// See: <https://firebase.google.com/docs/reference/rest/database/#section-patch>.
|
||||||
|
Future<dynamic> patch(dynamic uri, dynamic json) =>
|
||||||
|
send('PATCH', uri, json: json);
|
||||||
|
|
||||||
|
/// Deletes data from database using a HTTP DELETE request.
|
||||||
|
/// The response from a successful request contains a JSON with `null`.
|
||||||
|
///
|
||||||
|
/// See: <https://firebase.google.com/docs/reference/rest/database/#section-delete>.
|
||||||
|
Future<void> delete(dynamic uri) => send('DELETE', uri);
|
||||||
|
|
||||||
|
/// Creates a request with a HTTP [method], [url] and optional data.
|
||||||
|
/// The [url] can be either a `String` or `Uri`.
|
||||||
|
Future<Object?> send(String method, dynamic url, {dynamic json}) async {
|
||||||
|
final uri = url is String ? Uri.parse(url) : url as Uri;
|
||||||
|
|
||||||
|
final request = Request(method, uri);
|
||||||
|
if (credential != null) {
|
||||||
|
request.headers['Authorization'] = 'Bearer $credential';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (json != null) {
|
||||||
|
request.headers['Content-Type'] = 'application/json';
|
||||||
|
request.body = jsonEncode(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
final streamedResponse = await _client.send(request);
|
||||||
|
final response = await Response.fromStream(streamedResponse);
|
||||||
|
|
||||||
|
Object? bodyJson;
|
||||||
|
try {
|
||||||
|
bodyJson = jsonDecode(response.body);
|
||||||
|
} on FormatException {
|
||||||
|
final contentType = response.headers['content-type'];
|
||||||
|
if (contentType != null && !contentType.contains('application/json')) {
|
||||||
|
throw Exception(
|
||||||
|
"Returned value was not JSON. Did the uri end with '.json'?");
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.statusCode != 200) {
|
||||||
|
if (bodyJson is Map) {
|
||||||
|
final dynamic error = bodyJson['error'];
|
||||||
|
if (error != null) {
|
||||||
|
throw FirebaseClientException(response.statusCode, error.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw FirebaseClientException(response.statusCode, bodyJson.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return bodyJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Closes the client and cleans up any associated resources.
|
||||||
|
void close() => _client.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
class FirebaseClientException implements Exception {
|
||||||
|
FirebaseClientException(this.statusCode, this.message);
|
||||||
|
|
||||||
|
final int statusCode;
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => '$message ($statusCode)';
|
||||||
|
}
|
@ -1 +1,2 @@
|
|||||||
export 'cache_service.dart';
|
export 'cache_service.dart';
|
||||||
|
export 'firebase_client.dart';
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
name: hacki
|
name: hacki
|
||||||
description: A Hacker News reader.
|
description: A Hacker News reader.
|
||||||
version: 0.1.8+25
|
version: 0.1.9+27
|
||||||
publish_to: none
|
publish_to: none
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
@ -18,8 +18,6 @@ dependencies:
|
|||||||
equatable: 2.0.3
|
equatable: 2.0.3
|
||||||
fast_gbk: ^1.0.0
|
fast_gbk: ^1.0.0
|
||||||
feature_discovery: ^0.14.0
|
feature_discovery: ^0.14.0
|
||||||
firebase_analytics: ^8.3.4
|
|
||||||
firebase_core: ^1.6.0
|
|
||||||
flutter:
|
flutter:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
flutter_app_badger: ^1.3.0
|
flutter_app_badger: ^1.3.0
|
||||||
@ -46,6 +44,7 @@ dependencies:
|
|||||||
sembast: ^3.1.1+1
|
sembast: ^3.1.1+1
|
||||||
shared_preferences: ^2.0.11
|
shared_preferences: ^2.0.11
|
||||||
shimmer: ^2.0.0
|
shimmer: ^2.0.0
|
||||||
|
tuple: ^2.0.0
|
||||||
universal_platform: ^1.0.0+1
|
universal_platform: ^1.0.0+1
|
||||||
url_launcher: ^6.0.10
|
url_launcher: ^6.0.10
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user