Skip to content

Commit

Permalink
submit clippings in chunks
Browse files Browse the repository at this point in the history
  • Loading branch information
xinthink committed Nov 30, 2019
1 parent e8b4a64 commit b6153ba
Show file tree
Hide file tree
Showing 13 changed files with 263 additions and 71 deletions.
1 change: 1 addition & 0 deletions flt/lib/framework.dart
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export 'src/network/rest.dart';
export 'src/utils/collections.dart';
71 changes: 15 additions & 56 deletions flt/lib/src/screens/home.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,7 @@ import 'dart:async' show StreamSubscription;

import 'package:firebase/firebase.dart' show auth, User;
import 'package:flutter/material.dart';
import 'package:notever/framework.dart';
import 'package:notever/local.dart';
import 'package:notever/widgets.dart' show Login, ClippingList, ClippingListState;

import 'package:notever/src/models/clipping.dart';
import 'package:notever/widgets.dart' show Login, ClippingList, ClippingListState, ClippingUploaderFab;

class HomeScreen extends StatefulWidget {
@override
Expand All @@ -16,7 +12,9 @@ class HomeScreen extends StatefulWidget {
class _HomeScreenState extends State<HomeScreen> {
User currentUser;
int selectedClippingIndex;
bool isSyncing = false;

/// if clippings is being uploaded
bool isUploading = false;

final clippingListKey = GlobalKey<ClippingListState>();
StreamSubscription authStateSub;
Expand Down Expand Up @@ -62,6 +60,10 @@ class _HomeScreenState extends State<HomeScreen> {
onSelection: _onClippingSelected,
)
: _buildLoginWidget(),
// child: ClippingList(
// key: clippingListKey,
// onSelection: _onClippingSelected,
// ),
),
floatingActionButton: _buildFab(),
);
Expand All @@ -74,10 +76,10 @@ class _HomeScreenState extends State<HomeScreen> {
),
);

Widget _buildFab() => currentUser != null && selectedClippingIndex > -1 && !isSyncing
? FloatingActionButton(
child: const Icon(Icons.cloud_upload),
onPressed: _onSyncClippings,
Widget _buildFab() => currentUser != null && selectedClippingIndex > -1
? ClippingUploaderFab(
clippings: clippingListKey.currentState?.unsyncedClippings,
onComplete: _onClippingsUploaded,
)
: const SizedBox();

Expand Down Expand Up @@ -129,52 +131,9 @@ class _HomeScreenState extends State<HomeScreen> {
});
}

/// Start syncing clippings to Evernote
void _onSyncClippings() async {
final clippings = clippingListKey.currentState?.unsyncedClippings;
if (isSyncing || clippings?.isNotEmpty != true) return;

final message = """Are sure to import all clippings newer than your selection into your Evernote account?
${clippings.length} notes will be created.
Please confirm to continue.
""";
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Sync Clippings'),
content: Text(message),
actions: <Widget>[
FlatButton(
child: const Text('Cancel'),
onPressed: () => Navigator.of(context).pop(false),
),
FlatButton(
child: const Text('Continue'),
onPressed: () => Navigator.of(context).pop(true),
)
],
),
);
if (!confirmed) return;

try {
setState(() {
isSyncing = true;
});
final uri = '${EvernoteConfig.funcPrefix}/import.json';
await postJson(uri, body: {
'uid': currentUser.uid,
'clippings': Clipping.clippingsToJson(clippings),
});
Navigator.of(context).pushNamed('/jobs', arguments: currentUser.uid);
} catch (e) {
debugPrint('sync clippings request rejected: $e');
} finally {
setState(() {
isSyncing = false;
});
}
/// When clippings upload is complete
void _onClippingsUploaded() async {
Navigator.of(context).pushNamed('/jobs', arguments: currentUser.uid);
}
}

Expand Down
20 changes: 20 additions & 0 deletions flt/lib/src/utils/collections.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import 'dart:math';

/// Partition the given [list] into groups with size of [batchSize].
Iterable<Iterable<T>> partition<T>(List<T> list, int batchSize) {
final batches = List<List<T>>();
if (list?.isNotEmpty != true || batchSize == null || batchSize <= 0) return batches;

final total = list.length;
int offset = 0;
int remains = total;

while (offset < list.length && remains > 0) {
final size = min(remains, batchSize);
batches.add(list.sublist(offset, offset + size));
offset += size;
remains -= size;
}

return batches;
}
157 changes: 157 additions & 0 deletions flt/lib/src/widgets/clipping_uploader_fab.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import 'package:firebase/firebase.dart' show auth;
import 'package:flutter/material.dart';
import 'package:notever/framework.dart' show partition, postJson;
import 'package:notever/local.dart' show EvernoteConfig;
import 'package:notever/models.dart' show Clipping;
import 'package:uuid/uuid.dart';

/// A [FloatingActionButton] to upload selected [Clipping]s.
class ClippingUploaderFab extends StatefulWidget {
/// Instantiate a [ClippingUploaderFab].
///
/// To upload the given [clippings] when the FAB is clicked,
/// with an optional [onComplete] listener receiving notification when all clippings are uploaded (successfully or not).
const ClippingUploaderFab({
Key key,
this.clippings,
this.onComplete,
}) : super(key: key);

/// A list of [Clipping]s to be uploaded.
final List<Clipping> clippings;

/// Callback when all [Clipping]s are uploaded, no matter successful or not
final VoidCallback onComplete;

@override
State<StatefulWidget> createState() => _ClippingUploaderState();
}

/// [State] of the [ClippingUploaderFab] widget.
class _ClippingUploaderState extends State<ClippingUploaderFab> {
/// Current user's uid, should has a valid value when the uploader is visible
String get _currentUserID => auth().currentUser?.uid;

/// [Clipping]s to be uploaded.
List<Clipping> get _clippings => widget.clippings;

/// Whether clippings is being uploaded
bool _isUploading = false;

/// Total batches of [Clipping]s to be uploaded
int _totalBatches = 0;

/// Currently uploaded batches of [Clipping]s
int _uploadedBatches = 0;

/// Previous number of uploaded batches, used to display progress animation
int _prevUploadedBatches = 0;

@override
Widget build(BuildContext context) => Stack(
children: <Widget>[
_buildFab(),
if (_isUploading) _buildProgressIndicator(), // show progress when upload is started
],
);

/// Upload FAB
Widget _buildFab() => FloatingActionButton(
child: const Icon(Icons.cloud_upload),
tooltip: 'Upload',
onPressed: _isUploading ? null : _onPressed, // disabled when syncing
);

/// Show uploading progress
Widget _buildProgressIndicator() => Positioned(
width: 28,
height: 28,
top: 14,
left: 14,
child: TweenAnimationBuilder(
duration: const Duration(seconds: 5),
tween: Tween<double>(
begin: _prevUploadedBatches / _totalBatches,
end: _uploadedBatches / _totalBatches,
),
builder: (_, value, __) =>
CircularProgressIndicator(
value: value,
valueColor: const AlwaysStoppedAnimation(const Color(0x60ffffff)),
strokeWidth: 28,
),
),
);

/// Start syncing clippings to Evernote
void _onPressed() async {
if (_isUploading || _clippings?.isNotEmpty != true || _currentUserID?.isNotEmpty != true) return;

final message = """Are sure to import all clippings newer than your selection into your Evernote account?
${_clippings.length} notes will be created.
Please confirm to continue.
""";
final confirmed = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Sync Clippings'),
content: Text(message),
actions: <Widget>[
FlatButton(
child: const Text('Cancel'),
onPressed: () => Navigator.of(context).pop(false),
),
FlatButton(
child: const Text('Continue'),
onPressed: () => Navigator.of(context).pop(true),
)
],
),
);
if (!confirmed) return;

final batches = partition(_clippings, _BATCH_SIZE);
final taskId = Uuid().v4();

setState(() {
_isUploading = true;
_totalBatches = batches.length;
_prevUploadedBatches = _uploadedBatches = 0;
});

int i = 0;
for (var clippings in batches) {
await _uploadClippings(taskId, i, clippings);
await Future.delayed(const Duration(milliseconds: 25));
}

_notifyComplete();
}

Future<void> _uploadClippings(String taskId, int batchNo, Iterable<Clipping> clippings) async {
try {
final uri = '${EvernoteConfig.funcPrefix}/import.json';
await postJson(uri, body: {
'taskId': taskId,
'batch': batchNo,
'uid': _currentUserID,
'clippings': Clipping.clippingsToJson(clippings),
});
} catch (e) {
debugPrint('sync clippings request rejected: $e');
} finally {
setState(() {
// _isUploading = false;
_prevUploadedBatches = _uploadedBatches;
_uploadedBatches += 1;
});
}
}

void _notifyComplete() {
WidgetsBinding.instance.addPostFrameCallback((_) => widget.onComplete?.call());
}
}

const _BATCH_SIZE = 25;
1 change: 1 addition & 0 deletions flt/lib/widgets.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export 'src/widgets/clipping_list.dart';
export 'src/widgets/clipping_uploader_fab.dart';
export 'src/widgets/login.dart';
export 'src/widgets/job.dart';
7 changes: 7 additions & 0 deletions flt/pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,13 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.6"
uuid:
dependency: "direct main"
description:
name: uuid
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.4"
vector_math:
dependency: transitive
description:
Expand Down
1 change: 1 addition & 0 deletions flt/pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ dependencies:
firebase: ^6.0.0
http:
intl:
uuid:

dev_dependencies:
flutter_test:
Expand Down
43 changes: 43 additions & 0 deletions flt/test/utils/collections_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:notever/src/utils/collections.dart';

void main() {
test('partition a list', () {
var list = [1, 2, 3, 4, 5, 6];
var result = partition(list, 3);
expect(result, hasLength(2));
expect(result.first, hasLength(3));
expect(result.last, hasLength(3));
expect(result.last, [4, 5, 6]);

list = [3, 2];
result = partition(list, 2);
expect(result, hasLength(1));
expect(result.last, [3, 2]);
});

test('partition a list has an odd length', () {
var list = [1, 2, 3, 4, 5, 6, 7];
var result = partition(list, 3);
expect(result, hasLength(3));
expect(result.first, hasLength(3));
expect(result.last, hasLength(1));
expect(result.last, [7]);

list = [1, 2, 3];
result = partition(list, 3);
expect(result, hasLength(1));
expect(result.last, [1, 2, 3]);
});

test('partition an empty list should get an empty list', () {
expect(partition([], 3), isEmpty);
expect(partition(null, 3), isEmpty);
});

test('partition a list with invalid batch size', () {
expect(partition([1, 2], 0), isEmpty);
expect(partition([1, 2], -1), isEmpty);
expect(partition([1, 2], null), isEmpty);
});
}
1 change: 0 additions & 1 deletion functions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@
"passport-evernote": "^1.0.1",
"q": "^1.5.1",
"ramda": "^0.26.1",
"uuid": "^3.3.3",
"xml-escape": "^1.1.0"
},
"devDependencies": {
Expand Down
4 changes: 2 additions & 2 deletions functions/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import * as morgan from 'morgan';
import * as passport from 'passport';
import { Strategy as EvernoteStrategy } from 'passport-evernote';
import { assoc, partial } from 'ramda';
import * as uuid from 'uuid/v4';

import config from '../local';
import { FireSessionStore } from './FireSessionStore';
Expand Down Expand Up @@ -127,7 +126,8 @@ authApp.post('/import.json', (req, res) => {
const pubsub = new PubSub();
pubsub.topic(config.pubsub.importTopic)
.publishJSON(req.body, {
taskId: uuid(),
taskId: req.body.taskId,
batch: `${req.body.batch || ''}`,
createdAt: `${Date.now()}`,
})
.then(mid => {
Expand Down
Loading

0 comments on commit b6153ba

Please sign in to comment.