Compare commits

...

11 Commits

Author SHA1 Message Date
8348a87a75 Merge pull request #7 from Livinglist/v0.1.9
fixed notification screen.
2022-03-02 20:21:48 -08:00
58837b6c00 fixed notification screen. 2022-03-02 20:21:25 -08:00
8365869ee8 Merge pull request #6 from Livinglist/v0.1.9
v0.1.9
2022-03-02 16:41:45 -08:00
5c70185236 fixed ui. 2022-03-02 16:37:58 -08:00
e4a385deb7 fixed imports 2022-03-02 16:13:58 -08:00
dfde6a74eb fixed inconsistent font size. 2022-03-02 16:10:26 -08:00
a2223dc531 added the feature where tapping on comment in notification or history screen will lead user directly to the comment. 2022-03-02 16:03:33 -08:00
f75e6a5e3b updated README.md 2022-02-28 21:27:18 -08:00
a87d521d32 updated README.md 2022-02-28 17:23:28 -08:00
94d76d4c20 removed firebase package.
removed firebase package.
2022-02-28 15:50:46 -08:00
1176e3bb80 removed firebase package. 2022-02-28 15:46:23 -08:00
17 changed files with 331 additions and 85 deletions

View File

@ -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.
![iOS](https://img.shields.io/badge/iOS-13%20-blue)
[![App Store](https://img.shields.io/itunes/v/1602043763?label=App%20Store)](https://apps.apple.com/us/app/hacki/id1602043763)

View File

@ -0,0 +1 @@
- Tapping on comments in notification and history screen will lead you directly to the comment.

View File

@ -356,7 +356,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -365,7 +365,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1.8;
MARKETING_VERSION = 0.1.9;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -491,7 +491,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -500,7 +500,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1.8;
MARKETING_VERSION = 0.1.9;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -520,7 +520,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 4;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = QMWX3X2NF7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -529,7 +529,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 0.1.8;
MARKETING_VERSION = 0.1.9;
PRODUCT_BUNDLE_IDENTIFIER = com.jiaqi.hacki;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";

View File

@ -8,21 +8,31 @@ import 'package:hacki/services/cache_service.dart';
part 'comments_state.dart';
class CommentsCubit<T extends Item> extends Cubit<CommentsState> {
CommentsCubit(
{required T item,
CacheService? cacheService,
StoriesRepository? storiesRepository})
: _cacheService = cacheService ?? locator.get<CacheService>(),
CommentsCubit({
CacheService? cacheService,
StoriesRepository? storiesRepository,
}) : _cacheService = cacheService ?? locator.get<CacheService>(),
_storiesRepository =
storiesRepository ?? locator.get<StoriesRepository>(),
super(CommentsState.init()) {
init(item);
}
super(CommentsState.init());
final CacheService _cacheService;
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) {
final story = item;
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));
}
void loadAll(T item) {
emit(state.copyWith(
onlyShowTargetComment: false,
comments: [],
));
init(item);
}
void _onCommentFetched(Comment? comment) {
if (comment != null) {
_cacheService.cacheComment(comment);

View File

@ -13,30 +13,36 @@ class CommentsState extends Equatable {
required this.comments,
required this.status,
required this.collapsed,
required this.onlyShowTargetComment,
});
CommentsState.init()
: item = null,
comments = [],
status = CommentsStatus.init,
collapsed = false;
collapsed = false,
onlyShowTargetComment = false;
final Item? item;
final List<Comment> comments;
final CommentsStatus status;
final bool collapsed;
final bool onlyShowTargetComment;
CommentsState copyWith({
Item? item,
List<Comment>? comments,
CommentsStatus? status,
bool? collapsed,
bool? onlyShowTargetComment,
}) {
return CommentsState(
item: item ?? this.item,
comments: comments ?? this.comments,
status: status ?? this.status,
collapsed: collapsed ?? this.collapsed,
onlyShowTargetComment:
onlyShowTargetComment ?? this.onlyShowTargetComment,
);
}
@ -46,5 +52,6 @@ class CommentsState extends Equatable {
comments,
status,
collapsed,
onlyShowTargetComment,
];
}

View File

@ -3,7 +3,7 @@ import 'dart:math';
import 'package:bloc/bloc.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/cubits/cubits.dart';
import 'package:hacki/models/models.dart';

View File

@ -1,7 +1,8 @@
import 'package:firebase/firebase_io.dart';
import 'package:flutter/foundation.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/services/services.dart';
import 'package:html_unescape/html_unescape.dart';
import 'package:tuple/tuple.dart';
class StoriesRepository {
StoriesRepository({
@ -128,7 +129,7 @@ class StoriesRepository {
Future<Item?> fetchItemBy({required int id}) async {
final item = await _firebaseClient
.get('${_baseUrl}item/$id.json')
.then((dynamic val) {
.then((dynamic val) async {
if (val == null) {
return null;
}
@ -138,6 +139,9 @@ class StoriesRepository {
final story = Story.fromJson(json);
return story;
} 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);
return comment;
}
@ -172,6 +176,22 @@ class StoriesRepository {
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) {
return HtmlUnescape()
.convert(text)

View File

@ -248,9 +248,7 @@ class _HomeScreenState extends State<HomeScreen>
'to check out stories and comments you have '
'posted in the past, and get in-app '
'notification when there is new reply to '
'your comments or stories.\n\nAlso, you can '
'long press here to submit a new link to '
'Hacker News.',
'your comments or stories.',
style: TextStyle(fontSize: 16),
),
child: BlocBuilder<NotificationCubit,

View File

@ -40,6 +40,7 @@ class _ProfileScreenState extends State<ProfileScreen>
final refreshControllerFav = RefreshController();
final refreshControllerNotification = RefreshController();
final scrollController = ScrollController();
final throttle = Throttle(delay: const Duration(seconds: 2));
_PageType pageType = _PageType.notification;
@ -52,6 +53,16 @@ class _ProfileScreenState extends State<ProfileScreen>
'to infinity and beyond!',
];
@override
void dispose() {
super.dispose();
refreshControllerHistory.dispose();
refreshControllerFav.dispose();
refreshControllerNotification.dispose();
scrollController.dispose();
throttle.dispose();
}
@override
Widget build(BuildContext context) {
super.build(context);
@ -86,8 +97,9 @@ class _ProfileScreenState extends State<ProfileScreen>
}
},
builder: (context, historyState) {
if (!authState.isLoggedIn ||
historyState.submittedItems.isEmpty) {
if ((!authState.isLoggedIn ||
historyState.submittedItems.isEmpty) &&
historyState.status != HistoryStatus.loading) {
return const _CenteredMessageView(
content: 'Your past comments and stories will '
'show up here.',
@ -96,6 +108,7 @@ class _ProfileScreenState extends State<ProfileScreen>
return ItemsListView<Item>(
showWebPreview: false,
useConsistentFontSize: true,
refreshController: refreshControllerHistory,
items: historyState.submittedItems
.where((e) => !e.dead && !e.deleted)
@ -113,17 +126,7 @@ class _ProfileScreenState extends State<ProfileScreen>
StoryScreen.routeName,
arguments: StoryScreenArgs(story: item));
} else if (item is Comment) {
locator
.get<StoriesRepository>()
.fetchParentStory(id: item.parent)
.then((story) {
if (story != null && mounted) {
HackiApp.navigatorKey.currentState!
.pushNamed(StoryScreen.routeName,
arguments: StoryScreenArgs(
story: story));
}
});
onCommentTapped(item);
}
},
);
@ -144,7 +147,8 @@ class _ProfileScreenState extends State<ProfileScreen>
}
},
builder: (context, favState) {
if (favState.favStories.isEmpty) {
if (favState.favStories.isEmpty &&
favState.status != FavStatus.loading) {
return const _CenteredMessageView(
content:
'Your favorite stories will show up here.'
@ -190,22 +194,9 @@ class _ProfileScreenState extends State<ProfileScreen>
unreadCommentsIds:
notificationState.unreadCommentsIds,
comments: notificationState.comments,
onCommentTapped: (comment) {
locator
.get<StoriesRepository>()
.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,
),
);
}
onCommentTapped: (cmt) {
onCommentTapped(cmt, then: () {
context.read<NotificationCubit>().markAsRead(cmt);
});
},
onMarkAllAsReadTapped: () {
@ -381,7 +372,7 @@ class _ProfileScreenState extends State<ProfileScreen>
showAboutDialog(
context: context,
applicationName: 'Hacki',
applicationVersion: 'v0.1.8',
applicationVersion: 'v0.1.9',
applicationIcon: Image.asset(
Constants.hackiIconPath,
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() {
final usernameController = TextEditingController();
final passwordController = TextEditingController();

View File

@ -31,13 +31,21 @@ enum _MenuAction {
}
class StoryScreenArgs {
StoryScreenArgs({required this.story});
StoryScreenArgs({
required this.story,
this.onlyShowTargetComment = false,
this.targetComments,
});
final Story story;
final bool onlyShowTargetComment;
final List<Comment>? targetComments;
}
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';
@ -50,9 +58,12 @@ class StoryScreen extends StatefulWidget {
create: (context) => PostCubit(),
),
BlocProvider<CommentsCubit>(
create: (_) => CommentsCubit<Story>(
item: args.story,
),
create: (_) => CommentsCubit<Story>()
..init(
args.story,
onlyShowTargetComment: args.onlyShowTargetComment,
targetComment: args.targetComments?.last,
),
),
BlocProvider<EditCubit>(
create: (context) => EditCubit(),
@ -60,12 +71,14 @@ class StoryScreen extends StatefulWidget {
],
child: StoryScreen(
story: args.story,
parentComments: args.targetComments ?? [],
),
),
);
}
final Story story;
final List<Comment> parentComments;
@override
_StoryScreenState createState() => _StoryScreenState();
@ -338,7 +351,8 @@ class _StoryScreenState extends State<StoryScreen> {
),
body: SmartRefresher(
scrollController: scrollController,
enablePullUp: true,
enablePullUp: !state.onlyShowTargetComment,
enablePullDown: !state.onlyShowTargetComment,
header: WaterDropMaterialHeader(
backgroundColor: Colors.orange,
offset: topPadding,
@ -458,17 +472,37 @@ class _StoryScreenState extends State<StoryScreen> {
),
),
if (widget.story.text.isNotEmpty)
Html(
data: widget.story.text,
onLinkTap: (link, _, __, ___) =>
LinkUtil.launchUrl(link ?? ''),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
),
child: SelectableHtml(
data: widget.story.text,
onLinkTap: (link, _, __, ___) =>
LinkUtil.launchUrl(link ?? ''),
),
),
],
),
),
if (widget.story.text.isNotEmpty)
const SizedBox(
height: 8,
),
const Divider(
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 &&
state.status == CommentsStatus.loaded) ...[
const SizedBox(
@ -485,6 +519,11 @@ class _StoryScreenState extends State<StoryScreen> {
(e) => FadeIn(
child: CommentTile(
comment: e,
onlyShowTargetComment:
state.onlyShowTargetComment,
targetComments: widget.parentComments.sublist(
0,
max(widget.parentComments.length - 1, 0)),
myUsername: authState.isLoggedIn
? authState.username
: null,

View File

@ -76,20 +76,21 @@ class _ReplyBoxState extends State<ReplyBox> {
),
const Spacer(),
if (!widget.isLoading) ...[
if (widget.replyingTo != null) ...[
AnimatedOpacity(
opacity: expanded ? 1 : 0,
duration: const Duration(milliseconds: 300),
child: IconButton(
key: const Key('quote'),
icon: const Icon(
FeatherIcons.code,
color: Colors.orange,
size: 18,
...[
if (widget.replyingTo != null)
AnimatedOpacity(
opacity: expanded ? 1 : 0,
duration: const Duration(milliseconds: 300),
child: IconButton(
key: const Key('quote'),
icon: const Icon(
FeatherIcons.code,
color: Colors.orange,
size: 18,
),
onPressed: expanded ? showTextPopup : null,
),
onPressed: expanded ? showTextPopup : null,
),
),
IconButton(
key: const Key('expand'),
icon: Icon(

View File

@ -1,10 +1,12 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_fadein/flutter_fadein.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hacki/blocs/auth/auth_bloc.dart';
import 'package:hacki/blocs/blocs.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/utils/utils.dart';
@ -19,22 +21,31 @@ class CommentTile extends StatelessWidget {
required this.onEditTapped,
required this.onStoryLinkTapped,
this.loadKids = true,
this.onlyShowTargetComment = false,
this.level = 0,
this.targetComments = const [],
}) : super(key: key);
final String? myUsername;
final Comment comment;
final int level;
final bool loadKids;
final bool onlyShowTargetComment;
final Function(Comment) onReplyTapped;
final Function(Comment) onMoreTapped;
final Function(Comment) onEditTapped;
final Function(String) onStoryLinkTapped;
final List<Comment> targetComments;
@override
Widget build(BuildContext context) {
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>(
builder: (context, state) {
return BlocBuilder<PreferenceCubit, PreferenceState>(
@ -201,6 +212,16 @@ class CommentTile extends StatelessWidget {
(e) => FadeIn(
child: CommentTile(
comment: e,
onlyShowTargetComment:
onlyShowTargetComment &&
targetComments.length > 1,
targetComments: targetComments
.isNotEmpty
? targetComments.sublist(
0,
max(targetComments.length - 1,
0))
: [],
myUsername: myUsername,
onReplyTapped: onReplyTapped,
onMoreTapped: onMoreTapped,

View File

@ -6,8 +6,7 @@ import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hacki/cubits/cubits.dart';
import 'package:hacki/models/models.dart';
import 'package:hacki/screens/widgets/custom_circular_progress_indicator.dart';
import 'package:hacki/screens/widgets/story_tile.dart';
import 'package:hacki/screens/widgets/widgets.dart';
import 'package:hacki/utils/utils.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.pinnable = false,
this.markReadStories = false,
this.useConsistentFontSize = false,
this.onRefresh,
this.onLoadMore,
this.onPinned,
@ -34,6 +34,10 @@ class ItemsListView<T extends Item> extends StatelessWidget {
/// Whether story tiles can be pinned to the top.
final bool pinnable;
/// Whether to use same font size for comment and story tiles.
final bool useConsistentFontSize;
final List<T> items;
final Widget? header;
final RefreshController? refreshController;
@ -79,6 +83,7 @@ class ItemsListView<T extends Item> extends StatelessWidget {
onTap: () => onTap(e),
showWebPreview: showWebPreview,
wasRead: markReadStories && wasRead,
simpleTileFontSize: useConsistentFontSize ? 14 : 16,
),
),
),

View File

@ -14,12 +14,14 @@ class StoryTile extends StatelessWidget {
required this.showWebPreview,
required this.story,
required this.onTap,
this.simpleTileFontSize = 16,
}) : super(key: key);
final bool showWebPreview;
final bool wasRead;
final Story story;
final VoidCallback onTap;
final double simpleTileFontSize;
@override
Widget build(BuildContext context) {
@ -178,7 +180,7 @@ class StoryTile extends StatelessWidget {
story.title,
style: TextStyle(
color: wasRead ? Colors.grey[500] : null,
fontSize: 16,
fontSize: simpleTileFontSize,
),
),
),

View 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)';
}

View File

@ -1 +1,2 @@
export 'cache_service.dart';
export 'firebase_client.dart';

View File

@ -1,6 +1,6 @@
name: hacki
description: A Hacker News reader.
version: 0.1.8+25
version: 0.1.9+27
publish_to: none
environment:
@ -18,8 +18,6 @@ dependencies:
equatable: 2.0.3
fast_gbk: ^1.0.0
feature_discovery: ^0.14.0
firebase_analytics: ^8.3.4
firebase_core: ^1.6.0
flutter:
sdk: flutter
flutter_app_badger: ^1.3.0
@ -46,6 +44,7 @@ dependencies:
sembast: ^3.1.1+1
shared_preferences: ^2.0.11
shimmer: ^2.0.0
tuple: ^2.0.0
universal_platform: ^1.0.0+1
url_launcher: ^6.0.10