After Width: | Height: | Size: 868 B |
Before Width: | Height: | Size: 868 B After Width: | Height: | Size: 1.5 KiB |
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 3.3 KiB |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.3 KiB |
After Width: | Height: | Size: 3.7 KiB |
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.2 KiB |
After Width: | Height: | Size: 1.2 KiB |
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 2.3 KiB |
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 5.5 KiB |
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 5.5 KiB |
After Width: | Height: | Size: 6.3 KiB |
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 3.5 KiB |
After Width: | Height: | Size: 479 B |
Before Width: | Height: | Size: 479 B After Width: | Height: | Size: 974 B |
Before Width: | Height: | Size: 822 B After Width: | Height: | Size: 1.4 KiB |
Before Width: | Height: | Size: 762 B After Width: | Height: | Size: 1.5 KiB |
After Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 806 B After Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 1.0 KiB |
@ -0,0 +1,88 @@
|
||||
|
||||
import 'package:flutter/foundation.dart'; |
||||
|
||||
/// 图片消息的图片URL的HOST |
||||
const String chatImageHost = "http://skk8mlm5b.hn-bkt.clouddn.com/"; |
||||
|
||||
/// socket的host和port端口 |
||||
//47.93.216.24:9090 线上 192.168.10.200/192.168.10.129:9090 测试 |
||||
const String socketHost = kDebugMode ? '192.168.10.200' : '47.93.216.24'; |
||||
const num socketPort = 9090; |
||||
|
||||
const ipBaseUrl = "http://whois.pconline.com.cn"; |
||||
|
||||
/// 小程序接口的请求地址 |
||||
const localMiniBaseUrl = "http://192.168.10.54:8765/app/";///本地 |
||||
const serviceMiniBaseUrl = "https://pos.api.lotus-wallet.com/app/";///线上 |
||||
|
||||
/// app接口的请求地址 |
||||
const localBaseUrl = "http://192.168.10.54:8766/app/";///本地 |
||||
const serviceBaseUrl = "https://pos.platform.lotus-wallet.com/app/";///线上 |
||||
|
||||
/// 对list进行分组 |
||||
Map<S, List<T>> groupBy<S, T>(Iterable<T> values, S Function(T) key) { |
||||
var map = <S, List<T>>{}; |
||||
for (var element in values) { |
||||
(map[key(element)] ??= []).add(element); |
||||
} |
||||
return map; |
||||
} |
||||
|
||||
/// 对list进行分组计数 |
||||
Map<String, int> groupCount<S, T>(Map<S, List<T>> values) { |
||||
var map = <String, int>{}; |
||||
for (var element in values.keys) { |
||||
map["$element"] = values[element]?.length ?? 0; |
||||
} |
||||
return map; |
||||
} |
||||
|
||||
/// 对list进行分组并取最大值 |
||||
Map<String, T> groupItem<S, T>(Map<S, List<T>> values, {int Function(T) key}) { |
||||
var map = <String, T>{}; |
||||
for (var element in values.keys) { |
||||
if (values[element] == null) { |
||||
continue; |
||||
} |
||||
map["$element"] = key == null ? values[element].first : values[element].lMax(key); |
||||
} |
||||
return map; |
||||
} |
||||
|
||||
/// 最大值 |
||||
T max<T>(Iterable<T> list, int Function(T) key) { |
||||
T tt; |
||||
for (T t in list) { |
||||
if (tt == null) { |
||||
tt = t; |
||||
} |
||||
if (key(tt) < key(t)) { |
||||
tt = t; |
||||
} |
||||
} |
||||
return tt; |
||||
} |
||||
|
||||
|
||||
|
||||
extension ListExtension<S, T> on Iterable<T> { |
||||
|
||||
Map<S, List<T>> lGroupBy(S Function(T) key) { |
||||
return groupBy(this, key); |
||||
} |
||||
|
||||
T lMax(int Function(T) key) { |
||||
return max(this, key); |
||||
} |
||||
|
||||
} |
||||
|
||||
extension MapExtension<S, T> on Map<S, List<T>> { |
||||
|
||||
Map<String, int> get mGroupCount => groupCount(this); |
||||
|
||||
Map<String, T> mGroupItem({int Function(T) key}) { |
||||
return groupItem(this, key: key); |
||||
} |
||||
|
||||
} |
@ -1,103 +1,245 @@
|
||||
|
||||
|
||||
|
||||
import 'dart:async'; |
||||
import 'dart:convert'; |
||||
import 'dart:core'; |
||||
import 'dart:io'; |
||||
|
||||
import 'package:dio/dio.dart'; |
||||
import 'package:flutter/foundation.dart'; |
||||
import 'package:flutter/material.dart'; |
||||
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; |
||||
import 'package:huixiang/constant.dart'; |
||||
import 'package:huixiang/im/Proto.dart'; |
||||
import 'package:huixiang/im/database/message.dart'; |
||||
import 'package:huixiang/im/out/auth.pb.dart'; |
||||
import 'package:huixiang/im/out/message.pb.dart'; |
||||
import 'package:huixiang/main.dart'; |
||||
import 'package:image_gallery_saver/image_gallery_saver.dart'; |
||||
import 'package:path_provider/path_provider.dart'; |
||||
import 'package:shared_preferences/shared_preferences.dart'; |
||||
|
||||
class SocketClient { |
||||
|
||||
Socket _socket; |
||||
SharedPreferences shared; |
||||
SharedPreferences _shared; |
||||
Timer timer; |
||||
bool get heartbeatActive => timer != null && timer.isActive; |
||||
|
||||
connect() async { |
||||
shared = await SharedPreferences.getInstance(); |
||||
_shared = await SharedPreferences.getInstance(); |
||||
|
||||
if (_socket != null) { |
||||
reconnect(); |
||||
return; |
||||
} |
||||
|
||||
await Socket.connect('192.168.10.129', 9090).then((value) { |
||||
debugPrint("socket-connect"); |
||||
showDebugToast("socket-connect .... "); |
||||
await Socket.connect(socketHost, socketPort).then((value) { |
||||
debugPrint("socket-connect-$socketHost"); |
||||
_socket = value; |
||||
_socket.listen((data) { |
||||
print(data); |
||||
print("socket-listen"); |
||||
Proto proto = Proto.fromBytes(data); |
||||
print("socket-listen: $proto"); |
||||
MsgData data1 = MsgData.fromBuffer(proto.body); |
||||
print('收到来自:${data1.from},消息内容: ${utf8.decode(data1.data)} '); |
||||
|
||||
hxDatabase.messageDao.insertMessage(createMessage(mobile, utf8.decode(data1.data), userId: data1.from)); |
||||
|
||||
callbacks.forEach((callback) { |
||||
callback.call(data1); |
||||
MsgData dataResult = MsgData.fromBuffer(proto.body); |
||||
print('收到来自:${dataResult.from},消息内容: ${utf8.decode(dataResult.data)} '); |
||||
|
||||
receiveInsertMessage(dataResult).then((messageMap) { |
||||
if (callbacks[dataResult.from] != null) { |
||||
messageMap["state"] = 1; |
||||
} |
||||
hxDatabase.insert(messageMap).then((value) { |
||||
messageMap["id"] = value; |
||||
Message message = Message.fromJson(messageMap); |
||||
if (callbacks[dataResult.from] != null) { |
||||
callbacks[dataResult.from].call(message); /// user conversation callback |
||||
} |
||||
callbacks[userId]?.call(message); /// user self conversation list callback |
||||
}); |
||||
}); |
||||
|
||||
}, onError: (Object error, StackTrace stackTrace) { |
||||
debugPrint("socket-error: $error, stackTrace: ${stackTrace}"); |
||||
debugPrint("socket-listen-error: $error, stackTrace: $stackTrace"); |
||||
showDebugToast("socket-listen-error: $error, stackTrace: $stackTrace"); |
||||
reconnect(); |
||||
}, onDone: () { |
||||
debugPrint("socket-listen-down: down"); |
||||
}); |
||||
|
||||
authRequest(shared.getString("token")); |
||||
authRequest(_shared.getString("token")); |
||||
|
||||
heartbeat(); |
||||
}).catchError((error) { |
||||
debugPrint("socket-connect-error: $error"); |
||||
showDebugToast("socket-connect-error: $error"); |
||||
_socket = null; |
||||
reconnect(); |
||||
}); |
||||
} |
||||
|
||||
List<Function> callbacks = []; |
||||
Future<Map<String, dynamic>> receiveInsertMessage(MsgData dataResult) async { |
||||
Uint8List dataBytes = dataResult.data; |
||||
String content = utf8.decode(dataBytes); |
||||
|
||||
String attach = ""; |
||||
MsgType type = MsgType.values[dataResult.type.value]; |
||||
if (type == MsgType.IMAGE || type == MsgType.VIDEO || type == MsgType.AUDIO) { |
||||
|
||||
String filePath = await qiniu.downloadFile(content); |
||||
|
||||
Map<String, dynamic> result = await ImageGallerySaver.saveFile(filePath).catchError((error){}); |
||||
bool isSuccess = result["isSuccess"] != null && result["isSuccess"]; |
||||
attach = filePath; |
||||
} |
||||
|
||||
addCallback(Function callback) { |
||||
callbacks.add(callback); |
||||
Map<String, dynamic> messageMap = createMessage(userId, content, attach: attach, msgType: type.value, fromId: dataResult.from); |
||||
|
||||
return messageMap; |
||||
} |
||||
|
||||
showDebugToast(text) { |
||||
if (kDebugMode) { |
||||
SmartDialog.showToast(text, alignment: Alignment.center); |
||||
} |
||||
} |
||||
|
||||
removeCallback(Function callback) { |
||||
callbacks.remove(callback); |
||||
Proto heartbeatData() { |
||||
DateTime dateTime = DateTime.now(); |
||||
int millisecondsTime = dateTime.millisecondsSinceEpoch; |
||||
Uint8List data = utf8.encode(jsonEncode({"heartbeat": millisecondsTime})); |
||||
MsgData msgData = MsgData(from: userId, type: MsgType.TEXT, data: data); |
||||
final proto2 = Proto(3, 1, msgData.writeToBuffer()); |
||||
debugPrint("heartbeat: ${dateTime.toString()}"); |
||||
return proto2; |
||||
} |
||||
|
||||
heartbeat() { |
||||
cancelTimer(); |
||||
timer = Timer.periodic(const Duration(milliseconds: 30000), (timer) { |
||||
if(!checkSocket()) { |
||||
timer.cancel(); |
||||
return; |
||||
} |
||||
sendHeartBeatAndCheckSocket(); |
||||
}); |
||||
} |
||||
|
||||
/// send heartBeat and check socket is connected |
||||
/// send error: reconnect, |
||||
/// else check Timer.periodic isActive , |
||||
/// if not active: Timer.periodic send heartBeat |
||||
sendHeartBeatAndCheckSocket() { |
||||
final proto2 = heartbeatData(); |
||||
try { |
||||
_socket.add(proto2.toBytes()); |
||||
if (!socketClient.heartbeatActive) { |
||||
heartbeat(); |
||||
debugPrint("socket-periodic-send-heart-beat"); |
||||
} |
||||
} catch (e) { |
||||
debugPrint("socket-send-error: ${e.toString()}"); |
||||
showDebugToast("socket-send-error: ${e.toString()}"); |
||||
reconnect(); |
||||
} |
||||
} |
||||
|
||||
cancelTimer() { |
||||
if (timer != null && timer.isActive) { |
||||
timer.cancel(); |
||||
timer = null; |
||||
} |
||||
} |
||||
|
||||
reconnect() { |
||||
dispose(); |
||||
Future.delayed(Duration(milliseconds: 3000), () { |
||||
connect(); |
||||
}); |
||||
} |
||||
|
||||
Map<String, Function(Message message)> callbacks = <String, Function(Message message)>{}; |
||||
|
||||
addCallback(String userId, callback) { |
||||
callbacks[userId] = callback; |
||||
} |
||||
|
||||
removeCallback(String userId) { |
||||
callbacks.remove(userId); |
||||
} |
||||
|
||||
dispose() { |
||||
_socket.close(); |
||||
if (_socket != null) { |
||||
_socket.close(); |
||||
_socket = null; |
||||
} |
||||
} |
||||
|
||||
authRequest(String token) { |
||||
if (!checkSocket()) { |
||||
return; |
||||
} |
||||
debugPrint("socket-authRequest: request"); |
||||
final authReq = AuthReq() |
||||
..uid = mobile |
||||
..uid = userId |
||||
..token = token; |
||||
final authReqBytes = authReq.writeToBuffer(); |
||||
final proto = Proto(1, 1, authReqBytes); // 假设 operation 和 seqId 为 1 |
||||
final protoBytes = proto.toBytes(); |
||||
_socket.add(protoBytes); |
||||
try { |
||||
_socket.add(protoBytes); |
||||
} catch (e) { |
||||
debugPrint("socket-authRequest: $e"); |
||||
Future.delayed(const Duration(milliseconds: 1000), () { |
||||
authRequest(token); |
||||
}); |
||||
} |
||||
} |
||||
|
||||
sendMessage(int toId, String content) { |
||||
if (!checkSocket()) { |
||||
return; |
||||
} |
||||
Future<Message> sendMessage(String toId, String content, {String attach, int msgType = 1, replyId}) async { |
||||
MsgType type = MsgType.values[msgType]; |
||||
Uint8List data = utf8.encode(content); |
||||
MsgData msgData = MsgData(to: toId, from: mobile, type: MsgType.SINGLE_TEXT,data: data); |
||||
final proto2 = Proto(5, 1, msgData.writeToBuffer()); |
||||
_socket.add(proto2.toBytes()); |
||||
hxDatabase.messageDao.insertMessage(createMessage(toId, content, userId: mobile)).catchError((error) { |
||||
if (type == MsgType.IMAGE || type == MsgType.VIDEO || type == MsgType.AUDIO) { |
||||
File file = File(attach); |
||||
Directory dir = await getTemporaryDirectory(); |
||||
File newFile = await file.copy("${dir.path}/hx_${attach.split('/').last}"); |
||||
attach = newFile.path; |
||||
} |
||||
Map<String, dynamic> message = createMessage(toId, content, fromId: userId, attach: attach, msgType: msgType, replyId: replyId); |
||||
message["state"] = 1; |
||||
int id = await hxDatabase.insert(message).catchError((error) { |
||||
debugPrint("insertMessage: $error"); |
||||
}); |
||||
debugPrint("insertMessage: end"); |
||||
if (!checkSocket()) { |
||||
hxDatabase.update({"id": id, "state": 3}).catchError((error) { |
||||
debugPrint("insertMessage: ${error.toString()}"); |
||||
}); |
||||
message["id"] = id; |
||||
message["state"] = 3; |
||||
return Message.fromJson(message); |
||||
} |
||||
message["id"] = id; |
||||
|
||||
MsgData msgData = MsgData(to: toId, from: userId, type: type, data: data); |
||||
final proto2 = Proto(5, 1, msgData.writeToBuffer()); |
||||
try { |
||||
_socket.add(proto2.toBytes()); |
||||
debugPrint("socket-send-success:"); |
||||
} catch (e) { |
||||
hxDatabase.update({"id": id, "state": 3}).catchError((error) { |
||||
debugPrint("insertMessage: ${error.toString()}"); |
||||
}); |
||||
debugPrint("socket-send-error: ${e.toString()}"); |
||||
showDebugToast("socket-send-error: ${e.toString()}"); |
||||
reconnect(); |
||||
} |
||||
return Message.fromJson(message); |
||||
} |
||||
|
||||
checkSocket() { |
||||
if (_socket == null) { |
||||
connect(); |
||||
reconnect(); |
||||
return false; |
||||
} |
||||
return true; |
||||
} |
||||
|
||||
get mobile => 123456; |
||||
|
||||
String get userId => _shared.getString("userId"); |
||||
|
||||
} |
@ -1,18 +1,226 @@
|
||||
import 'package:flutter/cupertino.dart'; |
||||
import 'package:huixiang/constant.dart'; |
||||
import 'package:huixiang/im/database/message.dart'; |
||||
import 'package:huixiang/im/database/migration.dart'; |
||||
import 'package:shared_preferences/shared_preferences.dart'; |
||||
import 'package:sqflite/sqflite.dart'; |
||||
|
||||
import '../../retrofit/data/im_user.dart'; |
||||
|
||||
import 'dart:async'; |
||||
class HxDatabase { |
||||
Database db; |
||||
|
||||
import 'package:floor/floor.dart'; |
||||
import 'package:flutter/cupertino.dart'; |
||||
import 'package:huixiang/im/database/message_dao.dart'; |
||||
import 'package:huixiang/im/database/message.dart'; |
||||
import 'package:sqflite/sqflite.dart' as sqflite; |
||||
void open({String key}) async { |
||||
// _migrations.add(Migration(3, 4, (Database database) async { |
||||
// await database.execute('ALTER TABLE ImUser ADD COLUMN IF NOT EXISTS `isTop` INTEGER DEFAULT 0'); |
||||
// })); |
||||
|
||||
String databaseName = 'hx.db'; |
||||
if (key?.isNotEmpty ?? false) { |
||||
databaseName = 'hx_$key.db'; |
||||
} |
||||
await openDatabase(databaseName, version: 1, onCreate: (Database db, int version) async { |
||||
db.execute('CREATE TABLE IF NOT EXISTS `Message` (`id` INTEGER, `conversationId` VARCHAR(40), `fromId` VARCHAR(20), `toId` VARCHAR(20), `replyId` VARCHAR(20), `content` TEXT, `attach` TEXT, `msgType` INTEGER, `time` VARCHAR(20), `state` INTEGER, `isDelete` INTEGER, PRIMARY KEY (`id`))'); |
||||
db.execute('CREATE TABLE IF NOT EXISTS `ImUser` (`id` INTEGER, `mid` VARCHAR(20), `nickname` VARCHAR(20), `avatar` VARCHAR(200), `phone` VARCHAR(200), `isDelete` INTEGER, `isTop` INTEGER, PRIMARY KEY (`id`))'); |
||||
}, onConfigure: (database) async { |
||||
await database.execute('PRAGMA foreign_keys = ON'); |
||||
debugPrint("database-version: ${await database.getVersion()}"); |
||||
}, onUpgrade: (database, startVersion, endVersion) async { |
||||
await runMigrations(database, startVersion, endVersion, _migrations); |
||||
}, onOpen: (Database db) { |
||||
this.db = db; |
||||
}); |
||||
} |
||||
|
||||
void close() { |
||||
db.close(); |
||||
} |
||||
|
||||
_dbIsOpen() async { |
||||
if (db == null || !db.isOpen) { |
||||
var sp = await SharedPreferences.getInstance(); |
||||
open(key: sp.getString("userId")); |
||||
} |
||||
} |
||||
|
||||
Future<Message> lastMessage(String conversationId) async { |
||||
await _dbIsOpen(); |
||||
String sql = 'SELECT * FROM Message WHERE conversationId = ? ORDER BY time DESC LIMIT 1'; |
||||
List<Message> messages = await db.rawQuery(sql, [conversationId]).then((value) { |
||||
return value.map((e) { |
||||
debugPrint("Message: $e"); |
||||
return Message.fromJson(e); |
||||
}).toList(); |
||||
}, onError: (error) { |
||||
debugPrint("Message_error: $error"); |
||||
}); |
||||
return (messages?.isNotEmpty ?? false) ? messages.first : null; |
||||
} |
||||
|
||||
Future<List<Message>> queryList() async{ |
||||
await _dbIsOpen(); |
||||
String sql = '''SELECT * |
||||
FROM `Message` |
||||
WHERE ROWID IN ( |
||||
SELECT ROWID |
||||
FROM ( |
||||
SELECT ROWID, conversationId, MAX(`time`) AS max_time |
||||
FROM `Message` |
||||
GROUP BY conversationId |
||||
) AS grouped_messages |
||||
WHERE max_time = ( |
||||
SELECT MAX(`time`) |
||||
FROM `Message` |
||||
WHERE conversationId = grouped_messages.conversationId |
||||
) |
||||
) |
||||
ORDER BY `time` DESC;'''; |
||||
return db.rawQuery(sql).then((value) { |
||||
return value.map((e) { |
||||
debugPrint("Message: $e"); |
||||
return Message.fromJson(e); |
||||
}).toList(); |
||||
}, onError: (error) { |
||||
debugPrint("Message-error: $error"); |
||||
}); |
||||
} |
||||
|
||||
Future<List<Message>> queryUList(conversationId, {int page = 1, int pageSize = 10}) async{ |
||||
await _dbIsOpen(); |
||||
int start = (page - 1) * pageSize; |
||||
String sql = 'SELECT * FROM Message WHERE conversationId = ? ORDER BY time DESC LIMIT ?, ?'; |
||||
return db.rawQuery(sql, [conversationId, start, pageSize]).then((value) { |
||||
return value.map((e) => Message.fromJson(e)).toList(); |
||||
}, onError: (error) { |
||||
debugPrint("Message-error: $error"); |
||||
}); |
||||
} |
||||
|
||||
Future<List<Message>> queryTList(conversationId) async{ |
||||
await _dbIsOpen(); |
||||
String sql = 'SELECT *, time / 300000 * 300000 AS time_interval FROM Message WHERE conversationId = ? GROUP BY time_interval ORDER BY time DESC'; |
||||
return db.rawQuery(sql, [conversationId]).then((value) { |
||||
return value.map((e) => Message.fromJson(e)).toList(); |
||||
}, onError: (error) { |
||||
debugPrint("Message-error: $error"); |
||||
}); |
||||
} |
||||
|
||||
Future<Map<String, int>> messageUnreadCount(List<String> conversationIds) async { |
||||
await _dbIsOpen(); |
||||
String userStr = conversationIds.join("','"); |
||||
debugPrint("userStr: $userStr"); |
||||
List<Message> messages = await db.query("Message", |
||||
where: "conversationId IN ('$userStr') AND state = 0 AND isDelete = 0", |
||||
whereArgs: [], |
||||
).then((value) { |
||||
return value.map((e) => Message.fromJson(e)).toList(); |
||||
}, onError: (error) { |
||||
debugPrint("Message-error: $error"); |
||||
}); |
||||
return (messages??[]).lGroupBy((p) => p.conversationId).mGroupCount; |
||||
} |
||||
|
||||
Future<List<Map>> queryListAll() async{ |
||||
await _dbIsOpen(); |
||||
String sql = 'SELECT * FROM Message ORDER BY time DESC'; |
||||
return db.rawQuery(sql); |
||||
} |
||||
|
||||
Future<int> deleteByUser(String conversationId) async { |
||||
return db.delete("Message",where: "conversationId = ?", whereArgs: [conversationId]); |
||||
} |
||||
|
||||
Future<int> deleteByMsgId(String id) async { |
||||
return db.delete("Message",where: "id = ?", whereArgs: [id]); |
||||
} |
||||
|
||||
Future<int> deleteAll() async { |
||||
return db.delete("Message"); |
||||
} |
||||
|
||||
update(Map<dynamic, dynamic> message) async{ |
||||
await _dbIsOpen(); |
||||
debugPrint("Message_insert: $message"); |
||||
return db.update("Message", message, |
||||
where: 'id = ?', whereArgs: [message['id']]); |
||||
} |
||||
|
||||
Future<int> insert(Map message) async { |
||||
await _dbIsOpen(); |
||||
debugPrint("Message_insert: $message"); |
||||
return db.insert("Message", message); |
||||
} |
||||
|
||||
/// update message read state |
||||
readMessage(String conversationId) async{ |
||||
await _dbIsOpen(); |
||||
db.update("Message", {"state": 1}, |
||||
where: "conversationId = ? AND state = 0 AND isDelete = 0", |
||||
whereArgs: [conversationId]); |
||||
} |
||||
|
||||
Future<int> insertOrUpdateImUser(Map imUserMap) async { |
||||
await _dbIsOpen(); |
||||
debugPrint("imUser_insert: $imUserMap"); |
||||
if ((await queryImUserById(imUserMap['mid'])) == null) |
||||
return db.insert("ImUser", imUserMap); |
||||
else |
||||
return db.update("ImUser", imUserMap, |
||||
where: 'mid = ?', whereArgs: [imUserMap['mid']]); |
||||
} |
||||
|
||||
Future<List<ImUser>> queryImUser(List<String> userIds) async { |
||||
await _dbIsOpen(); |
||||
String query = |
||||
'SELECT * FROM ImUser WHERE mid IN (${userIds.map((mid) => "'$mid'").join(',')})'; |
||||
return db.rawQuery(query).then((value) { |
||||
return value.map((e) => ImUser.fromJson(e)).toList(); |
||||
}, onError: (error) { |
||||
debugPrint("ImUser_error: $error"); |
||||
}); |
||||
} |
||||
|
||||
Future<ImUser> queryImUserById(String userId) async { |
||||
await _dbIsOpen(); |
||||
List<ImUser> imUser = await db.query("ImUser", |
||||
distinct: true, where: "mid = ?", whereArgs: [userId]).then((value) { |
||||
return value.map((e) => ImUser.fromJson(e)).toList(); |
||||
}, onError: (error) { |
||||
debugPrint("ImUser_error: $error"); |
||||
}); |
||||
return (imUser?.isNotEmpty ?? false) ? imUser.first : null; |
||||
} |
||||
|
||||
final List<Migration> _migrations = []; |
||||
|
||||
part 'hx_database.g.dart'; |
||||
addMigrations(List<Migration> migrations) { |
||||
_migrations.addAll(migrations); |
||||
return this; |
||||
} |
||||
|
||||
@Database(version: 1, entities: [Message]) |
||||
abstract class HxDatabase extends FloorDatabase { |
||||
Future<void> runMigrations( |
||||
final Database migrationDatabase, |
||||
final int startVersion, |
||||
final int endVersion, |
||||
final List<Migration> migrations, |
||||
) async { |
||||
final relevantMigrations = migrations |
||||
.where((migration) => migration.startVersion >= startVersion) |
||||
.toList() |
||||
..sort( |
||||
(first, second) => first.startVersion.compareTo(second.startVersion)); |
||||
|
||||
MessageDao get messageDao; |
||||
if (relevantMigrations.isEmpty || |
||||
relevantMigrations.last.endVersion != endVersion) { |
||||
throw StateError( |
||||
'There is no migration supplied to update the database to the current version.' |
||||
' Aborting the migration.', |
||||
); |
||||
} |
||||
|
||||
for (final migration in relevantMigrations) { |
||||
await migration.migrate(migrationDatabase); |
||||
} |
||||
} |
||||
} |
||||
|
@ -1,144 +1,146 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND |
||||
|
||||
part of 'hx_database.dart'; |
||||
|
||||
// ************************************************************************** |
||||
// FloorGenerator |
||||
// ************************************************************************** |
||||
|
||||
// ignore: avoid_classes_with_only_static_members |
||||
class $FloorHxDatabase { |
||||
/// Creates a database builder for a persistent database. |
||||
/// Once a database is built, you should keep a reference to it and re-use it. |
||||
static _$HxDatabaseBuilder databaseBuilder(String name) => |
||||
_$HxDatabaseBuilder(name); |
||||
|
||||
/// Creates a database builder for an in memory database. |
||||
/// Information stored in an in memory database disappears when the process is killed. |
||||
/// Once a database is built, you should keep a reference to it and re-use it. |
||||
static _$HxDatabaseBuilder inMemoryDatabaseBuilder() => |
||||
_$HxDatabaseBuilder(null); |
||||
} |
||||
|
||||
class _$HxDatabaseBuilder { |
||||
_$HxDatabaseBuilder(this.name); |
||||
|
||||
final String name; |
||||
|
||||
final List<Migration> _migrations = []; |
||||
|
||||
Callback _callback; |
||||
|
||||
/// Adds migrations to the builder. |
||||
_$HxDatabaseBuilder addMigrations(List<Migration> migrations) { |
||||
_migrations.addAll(migrations); |
||||
return this; |
||||
} |
||||
|
||||
/// Adds a database [Callback] to the builder. |
||||
_$HxDatabaseBuilder addCallback(Callback callback) { |
||||
_callback = callback; |
||||
return this; |
||||
} |
||||
|
||||
/// Creates the database and initializes it. |
||||
Future<HxDatabase> build() async { |
||||
final path = name != null |
||||
? await sqfliteDatabaseFactory.getDatabasePath(name) |
||||
: ':memory:'; |
||||
final database = _$HxDatabase(); |
||||
database.database = await database.open( |
||||
path, |
||||
_migrations, |
||||
_callback, |
||||
); |
||||
return database; |
||||
} |
||||
} |
||||
|
||||
class _$HxDatabase extends HxDatabase { |
||||
_$HxDatabase([StreamController<String> listener]) { |
||||
changeListener = listener ?? StreamController<String>.broadcast(); |
||||
} |
||||
|
||||
MessageDao _messageDaoInstance; |
||||
|
||||
Future<sqflite.Database> open( |
||||
String path, |
||||
List<Migration> migrations, [ |
||||
Callback callback, |
||||
]) async { |
||||
final databaseOptions = sqflite.OpenDatabaseOptions( |
||||
version: 1, |
||||
onConfigure: (database) async { |
||||
await database.execute('PRAGMA foreign_keys = ON'); |
||||
await callback?.onConfigure?.call(database); |
||||
}, |
||||
onOpen: (database) async { |
||||
await callback?.onOpen?.call(database); |
||||
}, |
||||
onUpgrade: (database, startVersion, endVersion) async { |
||||
await MigrationAdapter.runMigrations( |
||||
database, startVersion, endVersion, migrations); |
||||
|
||||
await callback?.onUpgrade?.call(database, startVersion, endVersion); |
||||
}, |
||||
onCreate: (database, version) async { |
||||
await database.execute( |
||||
'CREATE TABLE IF NOT EXISTS `Message` (`id` INTEGER, `fromId` INTEGER, `toId` INTEGER, `content` TEXT, `attach` TEXT, `msgType` INTEGER, `time` INTEGER, `state` INTEGER, `isDelete` INTEGER, PRIMARY KEY (`id`))'); |
||||
|
||||
await callback?.onCreate?.call(database, version); |
||||
}, |
||||
); |
||||
return sqfliteDatabaseFactory.openDatabase(path, options: databaseOptions); |
||||
} |
||||
|
||||
@override |
||||
MessageDao get messageDao { |
||||
return _messageDaoInstance ??= _$MessageDao(database, changeListener); |
||||
} |
||||
} |
||||
|
||||
class _$MessageDao extends MessageDao { |
||||
_$MessageDao( |
||||
this.database, |
||||
this.changeListener, |
||||
) : _queryAdapter = QueryAdapter(database, changeListener), |
||||
_messageInsertionAdapter = InsertionAdapter( |
||||
database, |
||||
'Message', |
||||
(Message item) => item.toJson(), |
||||
changeListener); |
||||
|
||||
final sqflite.DatabaseExecutor database; |
||||
|
||||
final StreamController<String> changeListener; |
||||
|
||||
final QueryAdapter _queryAdapter; |
||||
|
||||
final InsertionAdapter<Message> _messageInsertionAdapter; |
||||
|
||||
@override |
||||
Stream<List<Message>> findMessageByToId(int toId) { |
||||
return _queryAdapter.queryListStream( |
||||
'SELECT * FROM Message WHERE toId = ?1', |
||||
mapper: (Map<String, Object> row) => Message.fromJson(row), |
||||
arguments: [toId], |
||||
queryableName: 'Message', |
||||
isView: false); |
||||
} |
||||
|
||||
@override |
||||
Future<List<Message>> findMessageByGroup(int userId) { |
||||
debugPrint("findMessageByGroup: $userId"); |
||||
return _queryAdapter.queryList( |
||||
'SELECT * FROM Message WHERE toId = ?1 OR fromId = ?2 GROUP BY toId,fromId ORDER BY time DESC', |
||||
mapper: (Map<String, Object> row) => Message.fromJson(row), |
||||
arguments: [userId, userId]); |
||||
} |
||||
|
||||
@override |
||||
Future<void> insertMessage(Message message) async { |
||||
await _messageInsertionAdapter.insert(message, OnConflictStrategy.abort); |
||||
} |
||||
} |
||||
// // GENERATED CODE - DO NOT MODIFY BY HAND |
||||
// |
||||
// part of 'hx_database.dart'; |
||||
// |
||||
// // ************************************************************************** |
||||
// // FloorGenerator |
||||
// // ************************************************************************** |
||||
// |
||||
// // ignore: avoid_classes_with_only_static_members |
||||
// import 'package:floor/floor.dart'; |
||||
// |
||||
// class $FloorHxDatabase { |
||||
// /// Creates a database builder for a persistent database. |
||||
// /// Once a database is built, you should keep a reference to it and re-use it. |
||||
// static _$HxDatabaseBuilder databaseBuilder(String name) => |
||||
// _$HxDatabaseBuilder(name); |
||||
// |
||||
// /// Creates a database builder for an in memory database. |
||||
// /// Information stored in an in memory database disappears when the process is killed. |
||||
// /// Once a database is built, you should keep a reference to it and re-use it. |
||||
// static _$HxDatabaseBuilder inMemoryDatabaseBuilder() => |
||||
// _$HxDatabaseBuilder(null); |
||||
// } |
||||
// |
||||
// class _$HxDatabaseBuilder { |
||||
// _$HxDatabaseBuilder(this.name); |
||||
// |
||||
// final String name; |
||||
// |
||||
// final List<Migration> _migrations = []; |
||||
// |
||||
// Callback _callback; |
||||
// |
||||
// /// Adds migrations to the builder. |
||||
// _$HxDatabaseBuilder addMigrations(List<Migration> migrations) { |
||||
// _migrations.addAll(migrations); |
||||
// return this; |
||||
// } |
||||
// |
||||
// /// Adds a database [Callback] to the builder. |
||||
// _$HxDatabaseBuilder addCallback(Callback callback) { |
||||
// _callback = callback; |
||||
// return this; |
||||
// } |
||||
// |
||||
// /// Creates the database and initializes it. |
||||
// Future<HxDatabase> build() async { |
||||
// final path = name != null |
||||
// ? await sqfliteDatabaseFactory.getDatabasePath(name) |
||||
// : ':memory:'; |
||||
// final database = _$HxDatabase(); |
||||
// database.database = await database.open( |
||||
// path, |
||||
// _migrations, |
||||
// _callback, |
||||
// ); |
||||
// return database; |
||||
// } |
||||
// } |
||||
// |
||||
// class _$HxDatabase extends HxDatabase { |
||||
// _$HxDatabase([StreamController<String> listener]) { |
||||
// changeListener = listener ?? StreamController<String>.broadcast(); |
||||
// } |
||||
// |
||||
// MessageDao _messageDaoInstance; |
||||
// |
||||
// Future<sqflite.Database> open( |
||||
// String path, |
||||
// List<Migration> migrations, [ |
||||
// Callback callback, |
||||
// ]) async { |
||||
// final databaseOptions = sqflite.OpenDatabaseOptions( |
||||
// version: 1, |
||||
// onConfigure: (database) async { |
||||
// await database.execute('PRAGMA foreign_keys = ON'); |
||||
// await callback?.onConfigure?.call(database); |
||||
// }, |
||||
// onOpen: (database) async { |
||||
// await callback?.onOpen?.call(database); |
||||
// }, |
||||
// onUpgrade: (database, startVersion, endVersion) async { |
||||
// await MigrationAdapter.runMigrations( |
||||
// database, startVersion, endVersion, migrations); |
||||
// |
||||
// await callback?.onUpgrade?.call(database, startVersion, endVersion); |
||||
// }, |
||||
// onCreate: (database, version) async { |
||||
// await database.execute( |
||||
// 'CREATE TABLE IF NOT EXISTS `Message` (`id` INTEGER, `fromId` INTEGER, `toId` INTEGER, `content` TEXT, `attach` TEXT, `msgType` INTEGER, `time` INTEGER, `state` INTEGER, `isDelete` INTEGER, PRIMARY KEY (`id`))'); |
||||
// |
||||
// await callback?.onCreate?.call(database, version); |
||||
// }, |
||||
// ); |
||||
// return sqfliteDatabaseFactory.openDatabase(path, options: databaseOptions); |
||||
// } |
||||
// |
||||
// @override |
||||
// MessageDao get messageDao { |
||||
// return _messageDaoInstance ??= _$MessageDao(database, changeListener); |
||||
// } |
||||
// } |
||||
// |
||||
// class _$MessageDao extends MessageDao { |
||||
// _$MessageDao( |
||||
// this.database, |
||||
// this.changeListener, |
||||
// ) : _queryAdapter = QueryAdapter(database, changeListener), |
||||
// _messageInsertionAdapter = InsertionAdapter( |
||||
// database, |
||||
// 'Message', |
||||
// (Message item) => item.toJson(), |
||||
// changeListener); |
||||
// |
||||
// final sqflite.DatabaseExecutor database; |
||||
// |
||||
// final StreamController<String> changeListener; |
||||
// |
||||
// final QueryAdapter _queryAdapter; |
||||
// |
||||
// final InsertionAdapter<Message> _messageInsertionAdapter; |
||||
// |
||||
// @override |
||||
// Stream<List<Message>> findMessageByToId(int toId) { |
||||
// return _queryAdapter.queryListStream( |
||||
// 'SELECT * FROM Message WHERE toId = ?1', |
||||
// mapper: (Map<String, Object> row) => Message.fromJson(row), |
||||
// arguments: [toId], |
||||
// queryableName: 'Message', |
||||
// isView: false); |
||||
// } |
||||
// |
||||
// @override |
||||
// Future<List<Message>> findMessageByGroup(int userId) { |
||||
// debugPrint("findMessageByGroup: $userId"); |
||||
// return _queryAdapter.queryList( |
||||
// 'SELECT * FROM Message WHERE toId = ?1 OR fromId = ?2 GROUP BY toId,fromId ORDER BY time DESC', |
||||
// mapper: (Map<String, Object> row) => Message.fromJson(row), |
||||
// arguments: [userId, userId]); |
||||
// } |
||||
// |
||||
// @override |
||||
// Future<void> insertMessage(Message message) async { |
||||
// await _messageInsertionAdapter.insert(message, OnConflictStrategy.abort); |
||||
// } |
||||
// } |
||||
|
@ -1,17 +1,17 @@
|
||||
import 'package:floor/floor.dart'; |
||||
import 'package:huixiang/im/database/message.dart'; |
||||
|
||||
|
||||
@dao |
||||
abstract class MessageDao { |
||||
|
||||
@Query('SELECT * FROM Message WHERE toId = :toId') |
||||
Stream<List<Message>> findMessageByToId(int toId); |
||||
|
||||
@insert |
||||
Future<void> insertMessage(Message message); |
||||
|
||||
@Query('SELECT * FROM Message WHERE toId = :userId OR fromId = :userId GROUP BY toId,fromId ORDER BY time DESC') |
||||
Future<List<Message>> findMessageByGroup(int userId); |
||||
|
||||
} |
||||
// import 'package:floor/floor.dart'; |
||||
// import 'package:huixiang/im/database/message.dart'; |
||||
// |
||||
// |
||||
// @dao |
||||
// abstract class MessageDao { |
||||
// |
||||
// @Query('SELECT * FROM Message WHERE toId = :toId') |
||||
// Stream<List<Message>> findMessageByToId(int toId); |
||||
// |
||||
// @insert |
||||
// Future<void> insertMessage(Message message); |
||||
// |
||||
// @Query('SELECT * FROM Message WHERE toId = :userId OR fromId = :userId GROUP BY toId,fromId ORDER BY time DESC') |
||||
// Future<List<Message>> findMessageByGroup(int userId); |
||||
// |
||||
// } |
@ -0,0 +1,41 @@
|
||||
import 'package:sqflite/sqflite.dart' as sqflite; |
||||
|
||||
/// Base class for a database migration. |
||||
/// |
||||
/// Each migration can move between 2 versions that are defined by |
||||
/// [startVersion] and [endVersion]. |
||||
class Migration { |
||||
/// The start version of the database. |
||||
final int startVersion; |
||||
|
||||
/// The start version of the database. |
||||
final int endVersion; |
||||
|
||||
/// Function that performs the migration. |
||||
final Future<void> Function(sqflite.Database database) migrate; |
||||
|
||||
/// Creates a new migration between [startVersion] and [endVersion]. |
||||
/// [migrate] will be called by the database and performs the actual |
||||
/// migration. |
||||
Migration(this.startVersion, this.endVersion, this.migrate) |
||||
: assert(startVersion > 0), |
||||
assert(startVersion < endVersion); |
||||
|
||||
@override |
||||
bool operator ==(Object other) => |
||||
identical(this, other) || |
||||
other is Migration && |
||||
runtimeType == other.runtimeType && |
||||
startVersion == other.startVersion && |
||||
endVersion == other.endVersion && |
||||
migrate == other.migrate; |
||||
|
||||
@override |
||||
int get hashCode => |
||||
startVersion.hashCode ^ endVersion.hashCode ^ migrate.hashCode; |
||||
|
||||
@override |
||||
String toString() { |
||||
return 'Migration{startVersion: $startVersion, endVersion: $endVersion, migrate: $migrate}'; |
||||
} |
||||
} |
@ -0,0 +1,365 @@
|
||||
import 'dart:ui'; |
||||
|
||||
import 'package:dio/dio.dart'; |
||||
import 'package:flutter/cupertino.dart'; |
||||
import 'package:flutter/material.dart'; |
||||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
||||
import 'package:flutter_smart_dialog/flutter_smart_dialog.dart'; |
||||
import 'package:shared_preferences/shared_preferences.dart'; |
||||
|
||||
import '../generated/l10n.dart'; |
||||
import '../retrofit/data/base_data.dart'; |
||||
import '../retrofit/data/im_user.dart'; |
||||
import '../retrofit/retrofit_api.dart'; |
||||
import '../utils/font_weight.dart'; |
||||
import '../view_widget/custom_image.dart'; |
||||
import '../view_widget/my_appbar.dart'; |
||||
import '../view_widget/settlement_tips_dialog.dart'; |
||||
|
||||
class ImSearch extends StatefulWidget { |
||||
@override |
||||
State<StatefulWidget> createState() { |
||||
return _ImSearch(); |
||||
} |
||||
} |
||||
|
||||
class _ImSearch extends State<ImSearch> { |
||||
ApiService apiService; |
||||
final TextEditingController editingController = TextEditingController(); |
||||
FocusNode _focusNode = FocusNode(); |
||||
List<ImUser> searchUser = []; |
||||
int searchState = 0; |
||||
int textType = 0; |
||||
int searchUserIndex = 0; |
||||
String selfUserId = ""; |
||||
|
||||
@override |
||||
void initState() { |
||||
super.initState(); |
||||
SharedPreferences.getInstance().then((value) { |
||||
selfUserId = value.getString("userId"); |
||||
}); |
||||
} |
||||
|
||||
///离开页面记着销毁和清除 |
||||
@override |
||||
void dispose() { |
||||
_focusNode.unfocus(); |
||||
super.dispose(); |
||||
} |
||||
|
||||
///搜索列表 |
||||
queryImSearch(keyword) async { |
||||
if (apiService == null) { |
||||
SharedPreferences value = await SharedPreferences.getInstance(); |
||||
apiService = ApiService(Dio(), |
||||
context: context, |
||||
token: value.getString("token"), |
||||
showLoading:false |
||||
); |
||||
} |
||||
BaseData<List<ImUser>> baseData = |
||||
await apiService.memberSearch(keyword).catchError((onError) {}); |
||||
if (baseData != null && baseData.isSuccess) { |
||||
searchUser.clear(); |
||||
baseData.data.forEach((element) { |
||||
if (element.phone != "" && element.nickname != "") { |
||||
searchUser.add(element); |
||||
} |
||||
}); |
||||
searchState = 1; |
||||
if (baseData.data.length == 0) { |
||||
searchState = 2; |
||||
} |
||||
setState(() {}); |
||||
} |
||||
} |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
return GestureDetector( |
||||
onTap: () { |
||||
FocusScope.of(context).requestFocus(FocusNode()); |
||||
}, |
||||
child: Scaffold( |
||||
backgroundColor: Color(0xFFFFFFFF), |
||||
resizeToAvoidBottomInset: false, |
||||
appBar: MyAppBar( |
||||
title: "搜索", |
||||
leadingColor: Colors.black, |
||||
background: Color(0xFFFFFFFF), |
||||
), |
||||
body: Container( |
||||
child: Column( |
||||
crossAxisAlignment: CrossAxisAlignment.start, |
||||
children: [ |
||||
Row( |
||||
crossAxisAlignment: CrossAxisAlignment.center, |
||||
mainAxisAlignment: MainAxisAlignment.center, |
||||
children: [ |
||||
Expanded(child: imSearch()), |
||||
GestureDetector( |
||||
behavior: HitTestBehavior.opaque, |
||||
onTap: () { |
||||
Navigator.of(context).pop(); |
||||
}, |
||||
child: Container( |
||||
margin: EdgeInsets.only(top: 8.h, bottom: 29.h), |
||||
alignment: Alignment.center, |
||||
padding: EdgeInsets.only(right: 16.w), |
||||
child: Text( |
||||
S.of(context).quxiao, |
||||
textAlign: TextAlign.center, |
||||
style: TextStyle( |
||||
color: Color(0xFFA29E9E), |
||||
fontSize: 14.sp, |
||||
fontWeight: MyFontWeight.medium, |
||||
), |
||||
)), |
||||
) |
||||
], |
||||
), |
||||
searchState == 2 |
||||
? Center( |
||||
child: Text( |
||||
"未找到该用户", |
||||
style: TextStyle( |
||||
fontSize: 14.sp, |
||||
color: Color(0xFFA29E9E), |
||||
), |
||||
), |
||||
) |
||||
: Expanded( |
||||
child: Column( |
||||
children: [ |
||||
Padding( |
||||
padding: EdgeInsets.only(bottom: 19.h), |
||||
child: Row( |
||||
crossAxisAlignment: CrossAxisAlignment.start, |
||||
children: [ |
||||
if (editingController.text != "" && |
||||
searchState == 1) |
||||
Padding( |
||||
padding: EdgeInsets.only(left: 16.w), |
||||
child: Text( |
||||
"搜索用户:", |
||||
textAlign: TextAlign.center, |
||||
style: TextStyle( |
||||
color: Color(0xFF060606), |
||||
fontSize: 16.sp, |
||||
fontWeight: MyFontWeight.medium, |
||||
), |
||||
), |
||||
), |
||||
if (editingController.text != "" && |
||||
searchState == 1) |
||||
Expanded( |
||||
child: Text( |
||||
editingController?.text ?? "", |
||||
style: TextStyle( |
||||
fontSize: 16.sp, |
||||
color: Color(0xFF32A060), |
||||
fontWeight: MyFontWeight.regular), |
||||
)) |
||||
], |
||||
), |
||||
), |
||||
if (editingController.text != "" && searchState == 1) |
||||
Expanded( |
||||
child: ListView.builder( |
||||
itemCount: searchUser?.length ?? 0, |
||||
physics: BouncingScrollPhysics(), |
||||
shrinkWrap: true, |
||||
itemBuilder: (context, position) { |
||||
return GestureDetector( |
||||
behavior: HitTestBehavior.opaque, |
||||
onTap: () { |
||||
setState(() { |
||||
searchUserIndex = position; |
||||
}); |
||||
Navigator.of(context).pushNamed( |
||||
'/router/personal_page', |
||||
arguments: { |
||||
"memberId": (searchUser[searchUserIndex] |
||||
.mid ?? |
||||
"") == |
||||
selfUserId |
||||
? "0" |
||||
: searchUser[searchUserIndex].mid, |
||||
"inletType": 0 |
||||
}); |
||||
FocusScope.of(context) |
||||
.requestFocus(FocusNode()); |
||||
}, |
||||
child: imSearchItem(searchUser[position]), |
||||
); |
||||
}, |
||||
)) |
||||
], |
||||
)), |
||||
], |
||||
), |
||||
), |
||||
), |
||||
); |
||||
} |
||||
|
||||
///搜索 |
||||
Widget imSearch() { |
||||
return Container( |
||||
margin: EdgeInsets.fromLTRB(16.w, 8.h, 16.w, 29.h), |
||||
padding: EdgeInsets.symmetric(vertical: 13.h), |
||||
decoration: BoxDecoration( |
||||
color: Color(0xFFFDFCFC), |
||||
borderRadius: BorderRadius.circular(4), |
||||
), |
||||
child: TextField( |
||||
textInputAction: TextInputAction.search, |
||||
onChanged: (value) { |
||||
setState(() { |
||||
searchState = 3; |
||||
}); |
||||
if (isNumeric(value)) { |
||||
textType = 1; |
||||
} else { |
||||
textType = 2; |
||||
} |
||||
if (editingController.text == null || editingController.text == "") { |
||||
return; |
||||
} else { |
||||
queryImSearch(editingController.text ?? ""); |
||||
} |
||||
}, |
||||
onEditingComplete: () { |
||||
FocusScope.of(context).requestFocus(FocusNode()); |
||||
if (editingController.text == null || editingController.text == "") { |
||||
SmartDialog.show( |
||||
widget: SettlementTips( |
||||
() {}, |
||||
text: "请输入姓名或手机号搜索", |
||||
)); |
||||
} else { |
||||
queryImSearch(editingController.text ?? ""); |
||||
} |
||||
}, |
||||
controller: editingController, |
||||
style: TextStyle( |
||||
fontSize: 14.sp, |
||||
), |
||||
decoration: InputDecoration( |
||||
hintText: "输入姓名或手机号搜索", |
||||
hintStyle: TextStyle( |
||||
fontSize: 14.sp, |
||||
color: Color(0xFFA29E9E), |
||||
), |
||||
isCollapsed: true, |
||||
prefixIcon: Padding( |
||||
padding: EdgeInsets.only(left: 15.w, right: 5.w), |
||||
child: Image.asset( |
||||
"assets/image/icon_search.webp", |
||||
width: 14.h, |
||||
height: 14.h, |
||||
color: Color(0xFFB3B3B3), |
||||
), |
||||
), |
||||
prefixIconConstraints: BoxConstraints(), |
||||
border: InputBorder.none, |
||||
), |
||||
), |
||||
); |
||||
} |
||||
|
||||
///搜索列表 |
||||
Widget imSearchItem(ImUser searchUser) { |
||||
return Container( |
||||
padding: EdgeInsets.only(left: 10.w, right: 16.w, bottom: 15.h), |
||||
child: Row( |
||||
children: [ |
||||
MImage( |
||||
searchUser?.avatar ?? "", |
||||
isCircle: true, |
||||
height: 54.h, |
||||
width: 54.h, |
||||
fit: BoxFit.cover, |
||||
errorSrc: "assets/image/default_1.webp", |
||||
fadeSrc: "assets/image/default_1.webp", |
||||
), |
||||
SizedBox( |
||||
width: 12.w, |
||||
), |
||||
Expanded( |
||||
child: Column( |
||||
crossAxisAlignment: CrossAxisAlignment.start, |
||||
children: [ |
||||
RichText( |
||||
text: TextSpan( |
||||
style: TextStyle(color: Colors.black), // 默认文本颜色为黑色 |
||||
children: _splitText(searchUser?.nickname ?? "", |
||||
editingController.text) |
||||
.map((part) { |
||||
return TextSpan( |
||||
text: part, |
||||
style: part == editingController.text |
||||
? TextStyle(color: Colors.green) // 匹配部分变为绿色 |
||||
: null, |
||||
); |
||||
}).toList(), |
||||
), |
||||
), |
||||
SizedBox( |
||||
height: 7.h, |
||||
), |
||||
RichText( |
||||
text: TextSpan( |
||||
style: TextStyle(color: Colors.black), // 默认文本颜色为黑色 |
||||
children: _splitText( |
||||
searchUser?.phone ?? "", editingController.text) |
||||
.map((part) { |
||||
return TextSpan( |
||||
text: part, |
||||
style: part == editingController.text |
||||
? TextStyle(color: Colors.green) // 匹配部分变为绿色 |
||||
: null, |
||||
); |
||||
}).toList(), |
||||
), |
||||
), |
||||
], |
||||
), |
||||
), |
||||
], |
||||
)); |
||||
} |
||||
|
||||
/// 搜索结构显示 |
||||
List<String> _splitText(String text, String search) { |
||||
if (text == null || text.isEmpty || search == null || search.isEmpty) { |
||||
throw ArgumentError('text and search must not be null or empty'); |
||||
} |
||||
|
||||
final List<String> parts = []; |
||||
int start = 0; |
||||
int index = text.indexOf(search); |
||||
|
||||
while (index != -1) { |
||||
if (index > start) { |
||||
parts.add(text.substring(start, index)); |
||||
} |
||||
parts.add(text.substring(index, index + search.length)); |
||||
start = index + search.length; |
||||
index = text.indexOf(search, start); |
||||
} |
||||
|
||||
if (start < text.length) { |
||||
parts.add(text.substring(start)); |
||||
} |
||||
|
||||
return parts; |
||||
} |
||||
|
||||
/// 判断给的字符串是否全部由数字组成 |
||||
bool isNumeric(String str) { |
||||
RegExp regExp = RegExp(r'^\d+$'); |
||||
return regExp.hasMatch(str); |
||||
} |
||||
} |
@ -0,0 +1,73 @@
|
||||
/// mid : "1379254113602109440" |
||||
/// nickname : "哈哈" |
||||
/// avatar : "https://pos.upload.lotus-wallet.com/admin/2022/11/d501d2cd-ffc0-49f2-967c-2e463462f500.jpeg" |
||||
/// phone : "13052919193" |
||||
/// isFollow : null |
||||
/// createTime : null |
||||
|
||||
class ImUser { |
||||
ImUser({ |
||||
String mid, |
||||
String nickname, |
||||
num isDelete, |
||||
num isTop, |
||||
String avatar, |
||||
String phone, }){ |
||||
_mid = mid; |
||||
_nickname = nickname; |
||||
_isDelete = isDelete; |
||||
_isTop = isTop; |
||||
_avatar = avatar; |
||||
_phone = phone; |
||||
} |
||||
|
||||
ImUser.fromJson(dynamic json) { |
||||
_mid = json['mid']; |
||||
_nickname = json['nickname']; |
||||
_isDelete = json['isDelete']; |
||||
_isTop = json['isTop']; |
||||
_avatar = json['avatar']; |
||||
_phone = json['phone']; |
||||
} |
||||
String _mid; |
||||
String _nickname; |
||||
num _isDelete; |
||||
num _isTop; |
||||
String _avatar; |
||||
String _phone; |
||||
ImUser copyWith({ String mid, |
||||
String nickname, |
||||
num isDelete, |
||||
num isTop, |
||||
String avatar, |
||||
String phone, |
||||
}) => ImUser( mid: mid ?? _mid, |
||||
nickname: nickname ?? _nickname, |
||||
isDelete: isDelete ?? _isDelete, |
||||
isTop: isTop ?? _isTop, |
||||
avatar: avatar ?? _avatar, |
||||
phone: phone ?? _phone, |
||||
); |
||||
String get mid => _mid; |
||||
String get nickname => _nickname; |
||||
num get isDelete => _isDelete; |
||||
num get isTop => _isTop; |
||||
String get avatar => _avatar; |
||||
String get phone => _phone; |
||||
|
||||
set isTop(num value) { |
||||
_isTop = value; |
||||
} |
||||
|
||||
Map<String, dynamic> toJson() { |
||||
final map = <String, dynamic>{}; |
||||
map['mid'] = _mid; |
||||
map['nickname'] = _nickname; |
||||
map['isDelete'] = _isDelete; |
||||
map['isTop'] = _isTop; |
||||
map['avatar'] = _avatar; |
||||
map['phone'] = _phone; |
||||
return map; |
||||
} |
||||
|
||||
} |
@ -0,0 +1,85 @@
|
||||
|
||||
|
||||
|
||||
import 'dart:io'; |
||||
|
||||
import 'package:dio/dio.dart'; |
||||
import 'package:flutter/cupertino.dart'; |
||||
import 'package:huixiang/constant.dart'; |
||||
import 'package:huixiang/retrofit/data/base_data.dart'; |
||||
import 'package:huixiang/retrofit/retrofit_api.dart'; |
||||
import 'package:path_provider/path_provider.dart'; |
||||
import 'package:qiniu_flutter_sdk/qiniu_flutter_sdk.dart'; |
||||
|
||||
class Qiniu { |
||||
|
||||
|
||||
Storage storage = Storage(config: Config()); |
||||
PutController putController = PutController(); |
||||
|
||||
progressListener() { |
||||
// 添加任务进度监听 |
||||
putController.addProgressListener((double percent) { |
||||
print('任务进度变化:已发送:$percent'); |
||||
}); |
||||
|
||||
// 添加文件发送进度监听 |
||||
putController.addSendProgressListener((double percent) { |
||||
print('已上传进度变化:已发送:$percent'); |
||||
}); |
||||
|
||||
// 添加任务状态监听 |
||||
putController.addStatusListener((StorageStatus status) { |
||||
print('状态变化: 当前任务状态:$status'); |
||||
}); |
||||
|
||||
} |
||||
|
||||
Future<String> uploadFile(ApiService apiService, String filePath) async { |
||||
|
||||
String token = await _getToken(apiService); |
||||
|
||||
File file = File(filePath); |
||||
|
||||
PutResponse putResponse = await storage.putFile(file, token, options: PutOptions( |
||||
controller: putController, |
||||
key: filePath.split('/').last, |
||||
)); |
||||
|
||||
debugPrint("qiniuToken-result: ${putResponse.toJson()}"); |
||||
|
||||
return "$chatImageHost/${putResponse.key}"; |
||||
} |
||||
|
||||
Future<String> _getToken(ApiService apiService) async { |
||||
BaseData<String> baseData = await apiService.getQiniuToken() |
||||
.catchError((error){ |
||||
debugPrint("getQiniuToken: $error"); |
||||
}); |
||||
if (baseData.isSuccess) { |
||||
return baseData.data; |
||||
} else { |
||||
debugPrint("get token fail, check network"); |
||||
throw Error(); |
||||
} |
||||
} |
||||
|
||||
final Dio dio = Dio(); |
||||
|
||||
Future<String> downloadFile(String urlPath, {String savePath}) async { |
||||
Directory dir = await getTemporaryDirectory(); |
||||
File newFile; |
||||
if (savePath != null && savePath != '') { |
||||
newFile = File(savePath); |
||||
} else { |
||||
newFile = File("${dir.path}/hx_${urlPath.split('/').last}"); |
||||
} |
||||
Response response = await dio.download(urlPath, newFile.path); |
||||
if (response.statusCode == 200) { |
||||
return newFile.path; |
||||
} |
||||
return null; |
||||
} |
||||
|
||||
} |
||||
|
@ -0,0 +1,58 @@
|
||||
language: dart |
||||
|
||||
dart: |
||||
- stable |
||||
|
||||
install: |
||||
- cd $TRAVIS_BUILD_DIR/base && pub get |
||||
# - cd $$TRAVIS_BUILD_DIR/flutter && pub get |
||||
|
||||
before_script: |
||||
- cd $TRAVIS_BUILD_DIR/base && rm -rf .env |
||||
|
||||
jobs: |
||||
include: |
||||
####################################### |
||||
######### jobs for base ############# |
||||
####################################### |
||||
# 检查 lint 并在 warnings、infos 时报错退出 |
||||
- stage: base(analyze,format,test) |
||||
name: "Analyze" |
||||
os: linux |
||||
script: cd $TRAVIS_BUILD_DIR/base && dartanalyzer --fatal-warnings --fatal-infos . |
||||
# 检查格式并在异常时退出 |
||||
- stage: base(analyze,format,test) |
||||
name: "Format" |
||||
os: linux |
||||
script: cd $TRAVIS_BUILD_DIR/base && dartfmt -n --set-exit-if-changed . |
||||
# 执行测试(已开启 null safe) |
||||
- stage: base(analyze,format,test) |
||||
name: "Vm Tests" |
||||
os: linux |
||||
script: cd $TRAVIS_BUILD_DIR/base && pub run test_coverage --print-test-output && bash <(curl -s https://codecov.io/bash) |
||||
|
||||
####################################### |
||||
####### jobs for flutter_sdk ########## |
||||
####################################### |
||||
# - stage: flutter_sdk(analyze,format,test) |
||||
# name: "Analyze" |
||||
# os: linux |
||||
# script: cd $TRAVIS_BUILD_DIR/flutter |
||||
# - stage: flutter_sdk(analyze,format,test) |
||||
# name: "Format" |
||||
# os: linux |
||||
# script: cd $TRAVIS_BUILD_DIR/flutter |
||||
|
||||
# - stage: flutter_sdk(analyze,format,test) |
||||
# name: "Vm Tests" |
||||
# os: linux |
||||
# script: cd $TRAVIS_BUILD_DIR/flutter |
||||
|
||||
stages: |
||||
- base(analyze,format,test) |
||||
# - flutter_sdk(analyze,format,test) |
||||
# - flutter_sdk_example(analyze,format,test) |
||||
|
||||
cache: |
||||
directories: |
||||
- $HOME/.pub-cache |
@ -0,0 +1,76 @@
|
||||
# Contributor Covenant Code of Conduct |
||||
|
||||
## Our Pledge |
||||
|
||||
In the interest of fostering an open and welcoming environment, we as |
||||
contributors and maintainers pledge to making participation in our project and |
||||
our community a harassment-free experience for everyone, regardless of age, body |
||||
size, disability, ethnicity, sex characteristics, gender identity and expression, |
||||
level of experience, education, socio-economic status, nationality, personal |
||||
appearance, race, religion, or sexual identity and orientation. |
||||
|
||||
## Our Standards |
||||
|
||||
Examples of behavior that contributes to creating a positive environment |
||||
include: |
||||
|
||||
* Using welcoming and inclusive language |
||||
* Being respectful of differing viewpoints and experiences |
||||
* Gracefully accepting constructive criticism |
||||
* Focusing on what is best for the community |
||||
* Showing empathy towards other community members |
||||
|
||||
Examples of unacceptable behavior by participants include: |
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or |
||||
advances |
||||
* Trolling, insulting/derogatory comments, and personal or political attacks |
||||
* Public or private harassment |
||||
* Publishing others' private information, such as a physical or electronic |
||||
address, without explicit permission |
||||
* Other conduct which could reasonably be considered inappropriate in a |
||||
professional setting |
||||
|
||||
## Our Responsibilities |
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable |
||||
behavior and are expected to take appropriate and fair corrective action in |
||||
response to any instances of unacceptable behavior. |
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or |
||||
reject comments, commits, code, wiki edits, issues, and other contributions |
||||
that are not aligned to this Code of Conduct, or to ban temporarily or |
||||
permanently any contributor for other behaviors that they deem inappropriate, |
||||
threatening, offensive, or harmful. |
||||
|
||||
## Scope |
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces |
||||
when an individual is representing the project or its community. Examples of |
||||
representing a project or community include using an official project e-mail |
||||
address, posting via an official social media account, or acting as an appointed |
||||
representative at an online or offline event. Representation of a project may be |
||||
further defined and clarified by project maintainers. |
||||
|
||||
## Enforcement |
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be |
||||
reported by contacting the project team at yinxulai@qiniu.com. All |
||||
complaints will be reviewed and investigated and will result in a response that |
||||
is deemed necessary and appropriate to the circumstances. The project team is |
||||
obligated to maintain confidentiality with regard to the reporter of an incident. |
||||
Further details of specific enforcement policies may be posted separately. |
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in good |
||||
faith may face temporary or permanent repercussions as determined by other |
||||
members of the project's leadership. |
||||
|
||||
## Attribution |
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, |
||||
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html |
||||
|
||||
[homepage]: https://www.contributor-covenant.org |
||||
|
||||
For answers to common questions about this code of conduct, see |
||||
https://www.contributor-covenant.org/faq |
@ -0,0 +1,15 @@
|
||||
# Dart SDK |
||||
|
||||
[![codecov](https://codecov.io/gh/qiniu/dart-sdk/branch/master/graph/badge.svg?token=5VOX6NJTKF)](https://codecov.io/gh/qiniu/dart-sdk) |
||||
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) |
||||
[![qiniu_sdk_base](https://img.shields.io/pub/v/qiniu_sdk_base.svg?label=qiniu_sdk_base)](https://pub.dev/packages/qiniu_sdk_base) |
||||
[![qiniu_flutter_sdk](https://img.shields.io/pub/v/qiniu_flutter_sdk.svg?label=qiniu_flutter_sdk)](https://pub.dev/packages/qiniu_flutter_sdk) |
||||
|
||||
## 目录说明 |
||||
|
||||
- base 封装了七牛各业务的基础实现 |
||||
- flutter 该目录是 base + Flutter 的绑定实现,同时导出为单独的 package 提供给用户使用 |
||||
|
||||
### [Flutter SDK](https://github.com/qiniu/dart-sdk/tree/master/flutter) |
||||
|
||||
七牛云业务基于 Dart 绑定 Flutter 的实现,为 Flutter 提供简易的使用方式,更多信息查看该目录下的 [README.md](https://github.com/qiniu/dart-sdk/tree/master/flutter/README.md) 文件。 |
@ -0,0 +1,12 @@
|
||||
## 0.1.0 |
||||
|
||||
- Initial Release. |
||||
|
||||
## 0.2.0 |
||||
|
||||
- 优化了 `StorageError` 输出的调用栈 |
||||
- `CacheProvider` 的方法都改成异步的 |
||||
|
||||
## 0.2.1 |
||||
|
||||
- 修复关闭 App 缓存丢失的问题 |
@ -0,0 +1,201 @@
|
||||
Apache License |
||||
Version 2.0, January 2004 |
||||
http://www.apache.org/licenses/ |
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION |
||||
|
||||
1. Definitions. |
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction, |
||||
and distribution as defined by Sections 1 through 9 of this document. |
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by |
||||
the copyright owner that is granting the License. |
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all |
||||
other entities that control, are controlled by, or are under common |
||||
control with that entity. For the purposes of this definition, |
||||
"control" means (i) the power, direct or indirect, to cause the |
||||
direction or management of such entity, whether by contract or |
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the |
||||
outstanding shares, or (iii) beneficial ownership of such entity. |
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity |
||||
exercising permissions granted by this License. |
||||
|
||||
"Source" form shall mean the preferred form for making modifications, |
||||
including but not limited to software source code, documentation |
||||
source, and configuration files. |
||||
|
||||
"Object" form shall mean any form resulting from mechanical |
||||
transformation or translation of a Source form, including but |
||||
not limited to compiled object code, generated documentation, |
||||
and conversions to other media types. |
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or |
||||
Object form, made available under the License, as indicated by a |
||||
copyright notice that is included in or attached to the work |
||||
(an example is provided in the Appendix below). |
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object |
||||
form, that is based on (or derived from) the Work and for which the |
||||
editorial revisions, annotations, elaborations, or other modifications |
||||
represent, as a whole, an original work of authorship. For the purposes |
||||
of this License, Derivative Works shall not include works that remain |
||||
separable from, or merely link (or bind by name) to the interfaces of, |
||||
the Work and Derivative Works thereof. |
||||
|
||||
"Contribution" shall mean any work of authorship, including |
||||
the original version of the Work and any modifications or additions |
||||
to that Work or Derivative Works thereof, that is intentionally |
||||
submitted to Licensor for inclusion in the Work by the copyright owner |
||||
or by an individual or Legal Entity authorized to submit on behalf of |
||||
the copyright owner. For the purposes of this definition, "submitted" |
||||
means any form of electronic, verbal, or written communication sent |
||||
to the Licensor or its representatives, including but not limited to |
||||
communication on electronic mailing lists, source code control systems, |
||||
and issue tracking systems that are managed by, or on behalf of, the |
||||
Licensor for the purpose of discussing and improving the Work, but |
||||
excluding communication that is conspicuously marked or otherwise |
||||
designated in writing by the copyright owner as "Not a Contribution." |
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity |
||||
on behalf of whom a Contribution has been received by Licensor and |
||||
subsequently incorporated within the Work. |
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of |
||||
this License, each Contributor hereby grants to You a perpetual, |
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable |
||||
copyright license to reproduce, prepare Derivative Works of, |
||||
publicly display, publicly perform, sublicense, and distribute the |
||||
Work and such Derivative Works in Source or Object form. |
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of |
||||
this License, each Contributor hereby grants to You a perpetual, |
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable |
||||
(except as stated in this section) patent license to make, have made, |
||||
use, offer to sell, sell, import, and otherwise transfer the Work, |
||||
where such license applies only to those patent claims licensable |
||||
by such Contributor that are necessarily infringed by their |
||||
Contribution(s) alone or by combination of their Contribution(s) |
||||
with the Work to which such Contribution(s) was submitted. If You |
||||
institute patent litigation against any entity (including a |
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work |
||||
or a Contribution incorporated within the Work constitutes direct |
||||
or contributory patent infringement, then any patent licenses |
||||
granted to You under this License for that Work shall terminate |
||||
as of the date such litigation is filed. |
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the |
||||
Work or Derivative Works thereof in any medium, with or without |
||||
modifications, and in Source or Object form, provided that You |
||||
meet the following conditions: |
||||
|
||||
(a) You must give any other recipients of the Work or |
||||
Derivative Works a copy of this License; and |
||||
|
||||
(b) You must cause any modified files to carry prominent notices |
||||
stating that You changed the files; and |
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works |
||||
that You distribute, all copyright, patent, trademark, and |
||||
attribution notices from the Source form of the Work, |
||||
excluding those notices that do not pertain to any part of |
||||
the Derivative Works; and |
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its |
||||
distribution, then any Derivative Works that You distribute must |
||||
include a readable copy of the attribution notices contained |
||||
within such NOTICE file, excluding those notices that do not |
||||
pertain to any part of the Derivative Works, in at least one |
||||
of the following places: within a NOTICE text file distributed |
||||
as part of the Derivative Works; within the Source form or |
||||
documentation, if provided along with the Derivative Works; or, |
||||
within a display generated by the Derivative Works, if and |
||||
wherever such third-party notices normally appear. The contents |
||||
of the NOTICE file are for informational purposes only and |
||||
do not modify the License. You may add Your own attribution |
||||
notices within Derivative Works that You distribute, alongside |
||||
or as an addendum to the NOTICE text from the Work, provided |
||||
that such additional attribution notices cannot be construed |
||||
as modifying the License. |
||||
|
||||
You may add Your own copyright statement to Your modifications and |
||||
may provide additional or different license terms and conditions |
||||
for use, reproduction, or distribution of Your modifications, or |
||||
for any such Derivative Works as a whole, provided Your use, |
||||
reproduction, and distribution of the Work otherwise complies with |
||||
the conditions stated in this License. |
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise, |
||||
any Contribution intentionally submitted for inclusion in the Work |
||||
by You to the Licensor shall be under the terms and conditions of |
||||
this License, without any additional terms or conditions. |
||||
Notwithstanding the above, nothing herein shall supersede or modify |
||||
the terms of any separate license agreement you may have executed |
||||
with Licensor regarding such Contributions. |
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade |
||||
names, trademarks, service marks, or product names of the Licensor, |
||||
except as required for reasonable and customary use in describing the |
||||
origin of the Work and reproducing the content of the NOTICE file. |
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or |
||||
agreed to in writing, Licensor provides the Work (and each |
||||
Contributor provides its Contributions) on an "AS IS" BASIS, |
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or |
||||
implied, including, without limitation, any warranties or conditions |
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A |
||||
PARTICULAR PURPOSE. You are solely responsible for determining the |
||||
appropriateness of using or redistributing the Work and assume any |
||||
risks associated with Your exercise of permissions under this License. |
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory, |
||||
whether in tort (including negligence), contract, or otherwise, |
||||
unless required by applicable law (such as deliberate and grossly |
||||
negligent acts) or agreed to in writing, shall any Contributor be |
||||
liable to You for damages, including any direct, indirect, special, |
||||
incidental, or consequential damages of any character arising as a |
||||
result of this License or out of the use or inability to use the |
||||
Work (including but not limited to damages for loss of goodwill, |
||||
work stoppage, computer failure or malfunction, or any and all |
||||
other commercial damages or losses), even if such Contributor |
||||
has been advised of the possibility of such damages. |
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing |
||||
the Work or Derivative Works thereof, You may choose to offer, |
||||
and charge a fee for, acceptance of support, warranty, indemnity, |
||||
or other liability obligations and/or rights consistent with this |
||||
License. However, in accepting such obligations, You may act only |
||||
on Your own behalf and on Your sole responsibility, not on behalf |
||||
of any other Contributor, and only if You agree to indemnify, |
||||
defend, and hold each Contributor harmless for any liability |
||||
incurred by, or claims asserted against, such Contributor by reason |
||||
of your accepting any such warranty or additional liability. |
||||
|
||||
END OF TERMS AND CONDITIONS |
||||
|
||||
APPENDIX: How to apply the Apache License to your work. |
||||
|
||||
To apply the Apache License to your work, attach the following |
||||
boilerplate notice, with the fields enclosed by brackets "[]" |
||||
replaced with your own identifying information. (Don't include |
||||
the brackets!) The text should be enclosed in the appropriate |
||||
comment syntax for the file format. We also recommend that a |
||||
file or class name and description of purpose be included on the |
||||
same "printed page" as the copyright notice for easier |
||||
identification within third-party archives. |
||||
|
||||
Copyright [yyyy] [name of copyright owner] |
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License"); |
||||
you may not use this file except in compliance with the License. |
||||
You may obtain a copy of the License at |
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0 |
||||
|
||||
Unless required by applicable law or agreed to in writing, software |
||||
distributed under the License is distributed on an "AS IS" BASIS, |
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
See the License for the specific language governing permissions and |
||||
limitations under the License. |
@ -0,0 +1,29 @@
|
||||
# Qiniu Sdk Base [![qiniu_sdk_base](https://img.shields.io/pub/v/qiniu_sdk_base.svg?label=qiniu_sdk_base)](https://pub.dev/packages/qiniu_sdk_base) [![codecov](https://codecov.io/gh/qiniu/dart-sdk/branch/master/graph/badge.svg?token=5VOX6NJTKF)](https://codecov.io/gh/qiniu/dart-sdk) |
||||
|
||||
七牛 dart 平台 sdk 的 base 包,为上层 sdk 提供基础设施和共享代码。 |
||||
|
||||
## 功能列表 |
||||
|
||||
* 单文件上传 |
||||
* 分片上传 |
||||
* 任务状态 |
||||
* 任务进度 |
||||
* 上传进度 |
||||
* 失败重试 |
||||
|
||||
## 如何测试 |
||||
|
||||
创建 `.env` 文件,并输入如下内容 |
||||
|
||||
``` |
||||
export QINIU_DART_SDK_ACCESS_KEY= |
||||
export QINIU_DART_SDK_SECRET_KEY= |
||||
export QINIU_DART_SDK_TOKEN_SCOPE= |
||||
``` |
||||
|
||||
|
||||
在 `.env` 文件中填好敏感数据,即 ak、sk、scope |
||||
|
||||
接着运行如下指令 |
||||
|
||||
`pub run test` |
@ -0,0 +1,90 @@
|
||||
# copy from https://github.com/dart-lang/http/blob/master/analysis_options.yaml |
||||
|
||||
include: package:pedantic/analysis_options.yaml |
||||
|
||||
analyzer: |
||||
# enable-experiment: |
||||
# - non-nullable |
||||
|
||||
strong-mode: |
||||
implicit-casts: false |
||||
implicit-dynamic: false |
||||
|
||||
linter: |
||||
rules: |
||||
- annotate_overrides |
||||
- avoid_bool_literals_in_conditional_expressions |
||||
- avoid_classes_with_only_static_members |
||||
- avoid_empty_else |
||||
- avoid_function_literals_in_foreach_calls |
||||
- avoid_init_to_null |
||||
- avoid_null_checks_in_equality_operators |
||||
- avoid_relative_lib_imports |
||||
- avoid_renaming_method_parameters |
||||
- avoid_return_types_on_setters |
||||
- avoid_returning_null_for_void |
||||
- avoid_returning_this |
||||
- avoid_shadowing_type_parameters |
||||
- avoid_single_cascade_in_expression_statements |
||||
- avoid_types_as_parameter_names |
||||
- avoid_unused_constructor_parameters |
||||
- await_only_futures |
||||
- camel_case_types |
||||
- cascade_invocations |
||||
# comment_references |
||||
- control_flow_in_finally |
||||
- curly_braces_in_flow_control_structures |
||||
- directives_ordering |
||||
- empty_catches |
||||
- empty_constructor_bodies |
||||
- empty_statements |
||||
- file_names |
||||
- hash_and_equals |
||||
- invariant_booleans |
||||
- iterable_contains_unrelated_type |
||||
- library_names |
||||
- library_prefixes |
||||
- list_remove_unrelated_type |
||||
- no_adjacent_strings_in_list |
||||
- no_duplicate_case_values |
||||
- non_constant_identifier_names |
||||
- null_closures |
||||
- omit_local_variable_types |
||||
- only_throw_errors |
||||
- overridden_fields |
||||
- package_names |
||||
- package_prefixed_library_names |
||||
- prefer_adjacent_string_concatenation |
||||
- prefer_conditional_assignment |
||||
- prefer_contains |
||||
- prefer_equal_for_default_values |
||||
- prefer_final_fields |
||||
- prefer_collection_literals |
||||
- prefer_generic_function_type_aliases |
||||
- prefer_initializing_formals |
||||
- prefer_is_empty |
||||
- prefer_is_not_empty |
||||
- prefer_null_aware_operators |
||||
- prefer_single_quotes |
||||
- prefer_typing_uninitialized_variables |
||||
- recursive_getters |
||||
- slash_for_doc_comments |
||||
- test_types_in_equals |
||||
- throw_in_finally |
||||
- type_init_formals |
||||
- unawaited_futures |
||||
- unnecessary_brace_in_string_interps |
||||
- unnecessary_const |
||||
- unnecessary_getters_setters |
||||
- unnecessary_lambdas |
||||
- unnecessary_new |
||||
- unnecessary_null_aware_assignments |
||||
- unnecessary_null_in_if_null_operators |
||||
- unnecessary_overrides |
||||
- unnecessary_parenthesis |
||||
- unnecessary_statements |
||||
- unnecessary_this |
||||
- unrelated_type_equality_checks |
||||
- use_rethrow_when_possible |
||||
- valid_regexps |
||||
- void_checks |
@ -0,0 +1,5 @@
|
||||
library qiniu_sdk_base; |
||||
|
||||
export 'src/auth/auth.dart'; |
||||
export 'src/error/error.dart'; |
||||
export 'src/storage/storage.dart'; |
@ -0,0 +1,125 @@
|
||||
import 'dart:convert'; |
||||
import 'package:crypto/crypto.dart'; |
||||
import 'package:meta/meta.dart'; |
||||
import './put_policy.dart'; |
||||
|
||||
export 'put_policy.dart'; |
||||
|
||||
class TokenInfo { |
||||
final String accessKey; |
||||
final PutPolicy putPolicy; |
||||
const TokenInfo(this.accessKey, this.putPolicy); |
||||
} |
||||
|
||||
/// 提供用于鉴权的相关功能。 |
||||
/// |
||||
/// 更多信息请查看[官方文档-安全机制](https://developer.qiniu.com/kodo/manual/1644/security) |
||||
class Auth { |
||||
/// 鉴权所需的 [accessKey]。 |
||||
/// |
||||
/// 更多信息请查看[官方文档-密钥 AccessKey/SecretKey](https://developer.qiniu.com/kodo/manual/1644/security#aksk) |
||||
/// 使用须知请查看[官方文档-密钥安全使用须知](https://developer.qiniu.com/kodo/kb/1334/the-access-key-secret-key-encryption-key-safe-use-instructions) |
||||
final String accessKey; |
||||
|
||||
/// 鉴权所需的 [secretKey]。 |
||||
/// |
||||
/// 如何生成以及查看、使用等请参阅 [accessKey] 的说明 |
||||
final String secretKey; |
||||
|
||||
const Auth({ |
||||
@required this.accessKey, |
||||
@required this.secretKey, |
||||
}) : assert(accessKey != null), |
||||
assert(secretKey != null); |
||||
|
||||
/// 根据上传策略生成上传使用的 Token。 |
||||
/// |
||||
/// 具体的上传策略说明请参考 [PutPolicy] 模块 |
||||
String generateUploadToken({ |
||||
@required PutPolicy putPolicy, |
||||
}) { |
||||
assert(putPolicy != null); |
||||
|
||||
var data = jsonEncode(putPolicy); |
||||
var encodedPutPolicy = base64Url.encode(utf8.encode(data)); |
||||
var baseToken = generateAccessToken(bytes: utf8.encode(encodedPutPolicy)); |
||||
return '$baseToken:$encodedPutPolicy'; |
||||
} |
||||
|
||||
/// 生成针对私有空间资源的下载 Token。 |
||||
/// |
||||
/// [key] 为对象的名称 |
||||
/// [deadline] 有效时间,单位为秒,例如 1451491200 |
||||
/// [bucketDomain] 空间绑定的域名,例如 http://test.bucket.com |
||||
String generateDownloadToken({ |
||||
@required String key, |
||||
@required int deadline, |
||||
@required String bucketDomain, |
||||
}) { |
||||
assert(key != null); |
||||
assert(deadline != null); |
||||
assert(bucketDomain != null); |
||||
|
||||
var downloadURL = '$bucketDomain/$key?e=$deadline'; |
||||
return generateAccessToken(bytes: utf8.encode(downloadURL)); |
||||
} |
||||
|
||||
/// 根据数据签名,生成 Token(用于接口的访问鉴权)。 |
||||
/// |
||||
/// 访问七牛的接口需要对请求进行签名, 该方法提供 Token 签发服务 |
||||
String generateAccessToken({@required List<int> bytes}) { |
||||
assert(bytes != null); |
||||
|
||||
var hmacEncoder = Hmac(sha1, utf8.encode(secretKey)); |
||||
|
||||
var sign = hmacEncoder.convert(bytes); |
||||
var encodedSign = base64Url.encode(sign.bytes); |
||||
return '$accessKey:$encodedSign'; |
||||
} |
||||
|
||||
/// 解析 token 信息。 |
||||
/// |
||||
/// 从 Token 字符串中解析 [accessKey]、[PutPolicy] 信息 |
||||
static TokenInfo parseToken(String token) { |
||||
assert(token != null && token != ''); |
||||
|
||||
var segments = token.split(':'); |
||||
if (segments.length < 2) { |
||||
throw ArgumentError('invalid token'); |
||||
} |
||||
|
||||
PutPolicy putPolicy; |
||||
var accessKey = segments.first; |
||||
|
||||
/// 具体的 token 信息可以参考这里。 |
||||
/// [内部文档](https://github.com/qbox/product/blob/master/kodo/auths/UpToken.md#admin-uptoken-authorization) |
||||
if (segments.length >= 3) { |
||||
if (segments.last == '') { |
||||
throw ArgumentError('invalid token'); |
||||
} |
||||
|
||||
putPolicy = PutPolicy.fromJson(jsonDecode( |
||||
String.fromCharCodes( |
||||
base64Url.decode( |
||||
segments.last, |
||||
), |
||||
), |
||||
) as Map<String, dynamic>); |
||||
} |
||||
|
||||
return TokenInfo(accessKey, putPolicy); |
||||
} |
||||
|
||||
/// 解析 up token 信息。 |
||||
/// |
||||
/// 从 Token 字符串中解析 [accessKey]、[PutPolicy] 信息 |
||||
static TokenInfo parseUpToken(String token) { |
||||
assert(token != null && token != ''); |
||||
final tokenInfo = parseToken(token); |
||||
if (tokenInfo.putPolicy == null) { |
||||
throw ArgumentError('invalid up token'); |
||||
} |
||||
|
||||
return tokenInfo; |
||||
} |
||||
} |
@ -0,0 +1,209 @@
|
||||
import 'package:meta/meta.dart'; |
||||
|
||||
/// 上传策略 |
||||
/// |
||||
/// 更多信息请查看[官方文档-上传策略](https://developer.qiniu.com/kodo/manual/1206/put-policy) |
||||
class PutPolicy { |
||||
/// 指定上传的目标资源空间 Bucket 和资源键 Key(最大为 750 字节)。 |
||||
/// |
||||
/// 有三种格式: |
||||
/// <Bucket> 表示允许用户上传文件到指定的 Bucket,在这种格式下文件只能新增。 |
||||
/// <Bucket>:<Key> 表示只允许用户上传指定 Key 的文件。在这种格式下文件默认允许修改。 |
||||
/// <Bucket>:<KeyPrefix> 表示只允许用户上传指定以 KeyPrefix 为前缀的文件。 |
||||
/// 具体信息一定请查看上述的上传策略文档! |
||||
final String scope; |
||||
|
||||
/// 获取 Bucket。 |
||||
/// |
||||
/// 从 [scope] 中获取 Bucket。 |
||||
String getBucket() { |
||||
return scope.split(':').first; |
||||
} |
||||
|
||||
/// 若为 1,表示允许用户上传以 [scope] 的 KeyPrefix 为前缀的文件。 |
||||
final int isPrefixalScope; |
||||
|
||||
/// 上传凭证有效截止时间。 |
||||
/// |
||||
/// Unix 时间戳,单位为秒, |
||||
/// 该截止时间为上传完成后,在七牛空间生成文件的校验时间, 而非上传的开始时间, |
||||
/// 一般建议设置为上传开始时间 + 3600s。 |
||||
final int deadline; |
||||
|
||||
/// 限制为新增文件。 |
||||
/// |
||||
/// 如果设置为非 0 值,则无论 [scope] 设置为什么形式,仅能以新增模式上传文件。 |
||||
final int insertOnly; |
||||
|
||||
/// 唯一属主标识。 |
||||
/// |
||||
/// 特殊场景下非常有用,例如根据 App-Client 标识给图片或视频打水印。 |
||||
final String endUser; |
||||
|
||||
/// Web 端文件上传成功后,浏览器执行 303 跳转的 URL。 |
||||
/// |
||||
/// 文件上传成功后会跳转到 <[returnUrl]>?upload_ret=<QueryString> |
||||
/// 其中 <QueryString> 包含 [returnBody] 内容。 |
||||
/// 如不设置 [returnUrl],则直接将 [returnBody] 的内容返回给客户端。 |
||||
final String returnUrl; |
||||
|
||||
/// [returnBody] 声明服务端的响应格式。 |
||||
/// |
||||
/// 可以使用 <魔法变量> 和 <自定义变量>,必须是合法的 JSON 格式, |
||||
/// 关于 <魔法变量> 请参阅:[官方文档-魔法变量](https://developer.qiniu.com/kodo/manual/1235/vars#magicvar) |
||||
/// 关于 <自定义变量> 请参阅:[官方文档-自定义变量](https://developer.qiniu.com/kodo/manual/1235/vars#xvar) |
||||
final String returnBody; |
||||
|
||||
/// 上传成功后,七牛云向业务服务器发送 POST 请求的 URL。 |
||||
final String callbackUrl; |
||||
|
||||
/// 上传成功后,七牛云向业务服务器发送回调通知时的 Host 值。 |
||||
/// |
||||
/// 与 [callbackUrl] 配合使用,仅当设置了 [callbackUrl] 时才有效。 |
||||
final String callbackHost; |
||||
|
||||
/// 上传成功后发起的回调请求。 |
||||
/// |
||||
/// 七牛云向业务服务器发送 Content-Type: application/x-www-form-urlencoded 的 POST 请求, |
||||
/// 例如:{"key":"$(key)","hash":"$(etag)","w":"$(imageInfo.width)","h":"$(imageInfo.height)"}, |
||||
/// 可以使用 <魔法变量> 和 <自定义变量>。 |
||||
final String callbackBody; |
||||
|
||||
/// 上传成功后发起的回调请求的 Content-Type。 |
||||
/// |
||||
/// 默认为 application/x-www-form-urlencoded,也可设置为 application/json。 |
||||
final String callbackBodyType; |
||||
|
||||
/// 资源上传成功后触发执行的预转持久化处理指令列表。 |
||||
/// |
||||
/// [fileType] = 2(上传归档存储文件)时,不支持使用该参数, |
||||
/// 每个指令是一个 API 规格字符串,多个指令用 ; 分隔, |
||||
/// 可以使用 <魔法变量> 和 <自定义变量>, |
||||
/// 改字段的具体使用信息可以查看:[官方文档-#persistentOps](https://developer.qiniu.com/kodo/manual/1206/put-policy#persistentOps) |
||||
final String persistentOps; |
||||
|
||||
/// 接收持久化处理结果通知的 URL。 |
||||
/// |
||||
/// 必须是公网上可以正常进行 POST 请求并能响应 HTTP/1.1 200 OK 的有效 URL, |
||||
/// 该 URL 获取的内容和持久化处理状态查询的处理结果一致, |
||||
/// 发送 body 格式是 Content-Type 为 application/json 的 POST 请求, |
||||
/// 需要按照读取流的形式读取请求的 body 才能获取。 |
||||
final String persistentNotifyUrl; |
||||
|
||||
/// 转码队列名。 |
||||
/// |
||||
/// 资源上传成功后,触发转码时指定独立的队列进行转码, |
||||
/// 为空则表示使用公用队列,处理速度比较慢。建议使用专用队列。 |
||||
final String persistentPipeline; |
||||
|
||||
/// [saveKey] 的优先级设置。 |
||||
/// |
||||
/// 该设置为 true 时,[saveKey] 不能为空,会忽略客户端指定的 Key,强制使用 [saveKey] 进行文件命名。 |
||||
/// 参数不设置时,默认值为 false。 |
||||
final String forceSaveKey; |
||||
|
||||
/// 自定义资源名。 |
||||
/// |
||||
/// 支持<魔法变量>和<自定义变量>, [forceSaveKey] 为 false 时, |
||||
/// 这个字段仅当用户上传的时候没有主动指定 key 时起作用, |
||||
/// [forceSaveKey] 为 true 时,将强制按这个字段的格式命名。 |
||||
final String saveKey; |
||||
|
||||
/// 限定上传文件大小最小值,单位 Byte。 |
||||
final int fsizeMin; |
||||
|
||||
/// 限定上传文件大小最大值,单位 Byte。 |
||||
/// |
||||
/// 超过限制上传文件大小的最大值会被判为上传失败,返回 413 状态码。 |
||||
final int fsizeLimit; |
||||
|
||||
/// 开启 MimeType 侦测功能。 |
||||
final int detectMime; |
||||
|
||||
/// 限定用户上传的文件类型。 |
||||
final String mimeLimit; |
||||
|
||||
/// 文件存储类型 |
||||
/// |
||||
/// 0 为标准存储(默认), |
||||
/// 1 为低频存储, |
||||
/// 2 为归档存储。 |
||||
final int fileType; |
||||
|
||||
const PutPolicy({ |
||||
@required this.scope, |
||||
@required this.deadline, |
||||
this.isPrefixalScope, |
||||
this.insertOnly, |
||||
this.endUser, |
||||
this.returnUrl, |
||||
this.returnBody, |
||||
this.callbackUrl, |
||||
this.callbackHost, |
||||
this.callbackBody, |
||||
this.callbackBodyType, |
||||
this.persistentOps, |
||||
this.persistentNotifyUrl, |
||||
this.persistentPipeline, |
||||
this.forceSaveKey, |
||||
this.saveKey, |
||||
this.fsizeMin, |
||||
this.fsizeLimit, |
||||
this.detectMime, |
||||
this.mimeLimit, |
||||
this.fileType, |
||||
}) : assert(scope != null), |
||||
assert(deadline != null); |
||||
|
||||
Map<String, dynamic> toJson() { |
||||
return <String, dynamic>{ |
||||
'scope': scope, |
||||
'isPrefixalScope': isPrefixalScope, |
||||
'deadline': deadline, |
||||
'insertOnly': insertOnly, |
||||
'endUser': endUser, |
||||
'returnUrl': returnUrl, |
||||
'returnBody': returnBody, |
||||
'callbackUrl': callbackUrl, |
||||
'callbackHost': callbackHost, |
||||
'callbackBody': callbackBody, |
||||
'callbackBodyType': callbackBodyType, |
||||
'persistentOps': persistentOps, |
||||
'persistentNotifyUrl': persistentNotifyUrl, |
||||
'persistentPipeline': persistentPipeline, |
||||
'forceSaveKey': forceSaveKey, |
||||
'saveKey': saveKey, |
||||
'fsizeMin': fsizeMin, |
||||
'fsizeLimit': fsizeLimit, |
||||
'detectMime': detectMime, |
||||
'mimeLimit': mimeLimit, |
||||
'fileType': fileType, |
||||
}..removeWhere((key, dynamic value) => value == null); |
||||
} |
||||
|
||||
factory PutPolicy.fromJson(Map<String, dynamic> json) { |
||||
return PutPolicy( |
||||
scope: json['scope'] as String, |
||||
deadline: json['deadline'] as int, |
||||
isPrefixalScope: json['isPrefixalScope'] as int, |
||||
insertOnly: json['insertOnly'] as int, |
||||
endUser: json['endUser'] as String, |
||||
returnUrl: json['returnUrl'] as String, |
||||
returnBody: json['returnBody'] as String, |
||||
callbackUrl: json['callbackUrl'] as String, |
||||
callbackHost: json['callbackHost'] as String, |
||||
callbackBody: json['callbackBody'] as String, |
||||
callbackBodyType: json['callbackBodyType'] as String, |
||||
persistentOps: json['persistentOps'] as String, |
||||
persistentNotifyUrl: json['persistentNotifyUrl'] as String, |
||||
persistentPipeline: json['persistentPipeline'] as String, |
||||
forceSaveKey: json['forceSaveKey'] as String, |
||||
saveKey: json['saveKey'] as String, |
||||
fsizeMin: json['fsizeMin'] as int, |
||||
fsizeLimit: json['fsizeLimit'] as int, |
||||
detectMime: json['detectMime'] as int, |
||||
mimeLimit: json['mimeLimit'] as String, |
||||
fileType: json['fileType'] as int, |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,12 @@
|
||||
class QiniuError extends Error { |
||||
final Error rawError; |
||||
|
||||
final String _message; |
||||
|
||||
String get message => _message ?? rawError?.toString() ?? ''; |
||||
|
||||
@override |
||||
StackTrace get stackTrace => rawError?.stackTrace ?? super.stackTrace; |
||||
|
||||
QiniuError({this.rawError, String message}) : _message = message; |
||||
} |
@ -0,0 +1,39 @@
|
||||
part of 'config.dart'; |
||||
|
||||
abstract class CacheProvider { |
||||
/// 设置一对数据 |
||||
Future setItem(String key, String item); |
||||
|
||||
/// 根据 key 获取缓存 |
||||
Future<String> getItem(String key); |
||||
|
||||
/// 删除指定 key 的缓存 |
||||
Future removeItem(String key); |
||||
|
||||
/// 清除所有 |
||||
Future clear(); |
||||
} |
||||
|
||||
class DefaultCacheProvider extends CacheProvider { |
||||
Map<String, String> value = {}; |
||||
|
||||
@override |
||||
Future clear() async { |
||||
value.clear(); |
||||
} |
||||
|
||||
@override |
||||
Future<String> getItem(String key) async { |
||||
return value[key]; |
||||
} |
||||
|
||||
@override |
||||
Future removeItem(String key) async { |
||||
value.remove(key); |
||||
} |
||||
|
||||
@override |
||||
Future setItem(String key, String item) async { |
||||
value[key] = item; |
||||
} |
||||
} |
@ -0,0 +1,28 @@
|
||||
import 'package:dio/adapter.dart'; |
||||
import 'package:dio/dio.dart'; |
||||
import 'package:meta/meta.dart'; |
||||
import 'package:qiniu_sdk_base/src/storage/error/error.dart'; |
||||
|
||||
part 'protocol.dart'; |
||||
part 'host.dart'; |
||||
part 'cache.dart'; |
||||
|
||||
class Config { |
||||
final HostProvider hostProvider; |
||||
final CacheProvider cacheProvider; |
||||
final HttpClientAdapter httpClientAdapter; |
||||
|
||||
/// 重试次数 |
||||
/// |
||||
/// 各种网络请求失败的重试次数 |
||||
final int retryLimit; |
||||
|
||||
Config({ |
||||
HostProvider hostProvider, |
||||
CacheProvider cacheProvider, |
||||
HttpClientAdapter httpClientAdapter, |
||||
this.retryLimit = 3, |
||||
}) : hostProvider = hostProvider ?? DefaultHostProvider(), |
||||
cacheProvider = cacheProvider ?? DefaultCacheProvider(), |
||||
httpClientAdapter = httpClientAdapter ?? DefaultHttpClientAdapter(); |
||||
} |
@ -0,0 +1,124 @@
|
||||
part of 'config.dart'; |
||||
|
||||
abstract class HostProvider { |
||||
Future<String> getUpHost({ |
||||
@required String accessKey, |
||||
@required String bucket, |
||||
}); |
||||
|
||||
bool isFrozen(String host); |
||||
|
||||
void freezeHost(String host); |
||||
} |
||||
|
||||
class DefaultHostProvider extends HostProvider { |
||||
final protocol = Protocol.Https.value; |
||||
|
||||
final _http = Dio(); |
||||
// 缓存的上传区域 |
||||
final _stashedUpDomains = <_Domain>[]; |
||||
// accessKey:bucket 用此 key 判断是否 up host 需要走缓存 |
||||
String _cacheKey; |
||||
// 冻结的上传区域 |
||||
final List<_Domain> _frozenUpDomains = []; |
||||
|
||||
@override |
||||
Future<String> getUpHost({ |
||||
@required String accessKey, |
||||
@required String bucket, |
||||
}) async { |
||||
// 解冻需要被解冻的 host |
||||
_frozenUpDomains.removeWhere((domain) => !domain.isFrozen()); |
||||
|
||||
var _upDomains = <_Domain>[]; |
||||
if ('$accessKey:$bucket' == _cacheKey && _stashedUpDomains.isNotEmpty) { |
||||
_upDomains.addAll(_stashedUpDomains); |
||||
} else { |
||||
final url = |
||||
'$protocol://api.qiniu.com/v4/query?ak=$accessKey&bucket=$bucket'; |
||||
|
||||
final res = await _http.get<Map>(url); |
||||
|
||||
final hosts = res.data['hosts'] |
||||
.map((dynamic json) => _Host.fromJson(json as Map)) |
||||
.cast<_Host>() |
||||
.toList() as List<_Host>; |
||||
|
||||
for (var host in hosts) { |
||||
final domainList = host.up['domains'].cast<String>() as List<String>; |
||||
final domains = domainList.map((domain) => _Domain(domain)); |
||||
_upDomains.addAll(domains); |
||||
} |
||||
|
||||
_cacheKey = '$accessKey:$bucket'; |
||||
_stashedUpDomains.addAll(_upDomains); |
||||
} |
||||
|
||||
// 每次都从头遍历一遍,最合适的 host 总是会排在最前面 |
||||
for (var index = 0; index < _upDomains.length; index++) { |
||||
final availableDomain = _upDomains.elementAt(index); |
||||
// 检查看起来可用的 host 是否之前被冻结过 |
||||
final frozen = isFrozen(protocol + '://' + availableDomain.value); |
||||
|
||||
if (!frozen) { |
||||
return protocol + '://' + availableDomain.value; |
||||
} |
||||
} |
||||
// 全部被冻结,几乎不存在的情况 |
||||
throw StorageError( |
||||
type: StorageErrorType.NO_AVAILABLE_HOST, |
||||
message: '没有可用的上传域名', |
||||
); |
||||
} |
||||
|
||||
@override |
||||
bool isFrozen(String host) { |
||||
final uri = Uri.parse(host); |
||||
final frozenDomain = _frozenUpDomains.firstWhere( |
||||
(domain) => domain.isFrozen() && domain.value == uri.host, |
||||
orElse: () => null); |
||||
return frozenDomain != null; |
||||
} |
||||
|
||||
@override |
||||
void freezeHost(String host) { |
||||
// http://example.org |
||||
// scheme: http |
||||
// host: example.org |
||||
final uri = Uri.parse(host); |
||||
_frozenUpDomains.add(_Domain(uri.host)..freeze()); |
||||
} |
||||
} |
||||
|
||||
class _Host { |
||||
String region; |
||||
int ttl; |
||||
// domains: [] |
||||
Map<String, dynamic> up; |
||||
|
||||
_Host({this.region, this.ttl, this.up}); |
||||
|
||||
factory _Host.fromJson(Map json) { |
||||
return _Host( |
||||
region: json['region'] as String, |
||||
ttl: json['ttl'] as int, |
||||
up: json['up'] as Map<String, dynamic>, |
||||
); |
||||
} |
||||
} |
||||
|
||||
class _Domain { |
||||
int frozenTime = 0; |
||||
final _lockTime = 1000 * 60 * 10; |
||||
|
||||
bool isFrozen() { |
||||
return frozenTime + _lockTime > DateTime.now().millisecondsSinceEpoch; |
||||
} |
||||
|
||||
void freeze() { |
||||
frozenTime = DateTime.now().millisecondsSinceEpoch; |
||||
} |
||||
|
||||
String value; |
||||
_Domain(this.value); |
||||
} |
@ -0,0 +1,11 @@
|
||||
part of 'config.dart'; |
||||
|
||||
enum Protocol { Http, Https } |
||||
|
||||
extension ProtocolExt on Protocol { |
||||
String get value { |
||||
if (this == Protocol.Http) return 'http'; |
||||
if (this == Protocol.Https) return 'https'; |
||||
return 'https'; |
||||
} |
||||
} |
@ -0,0 +1,75 @@
|
||||
import 'package:dio/dio.dart'; |
||||
import 'package:qiniu_sdk_base/src/error/error.dart'; |
||||
|
||||
enum StorageErrorType { |
||||
/// 连接超时 |
||||
CONNECT_TIMEOUT, |
||||
|
||||
/// 发送超时 |
||||
SEND_TIMEOUT, |
||||
|
||||
/// 接收超时 |
||||
RECEIVE_TIMEOUT, |
||||
|
||||
/// 服务端响应了但是状态码是 400 以上 |
||||
RESPONSE, |
||||
|
||||
/// 请求被取消 |
||||
CANCEL, |
||||
|
||||
/// 没有可用的服务器 |
||||
NO_AVAILABLE_HOST, |
||||
|
||||
/// 已在处理队列中 |
||||
IN_PROGRESS, |
||||
|
||||
/// 未知或者不能处理的错误 |
||||
UNKNOWN, |
||||
} |
||||
|
||||
class StorageError extends QiniuError { |
||||
/// [type] 不是 [StorageErrorType.RESPONSE] 的时候为 null |
||||
final int code; |
||||
final StorageErrorType type; |
||||
|
||||
StorageError({this.type, this.code, Error rawError, String message}) |
||||
: super(rawError: rawError, message: message); |
||||
|
||||
factory StorageError.fromError(Error error) { |
||||
return StorageError(type: StorageErrorType.UNKNOWN, rawError: error); |
||||
} |
||||
|
||||
factory StorageError.fromDioError(DioError error) { |
||||
return StorageError( |
||||
type: _mapDioErrorType(error.type), |
||||
code: error.response?.statusCode, |
||||
message: error.response?.data.toString(), |
||||
rawError: error.error is Error ? (error.error as Error) : null, |
||||
); |
||||
} |
||||
|
||||
@override |
||||
String toString() { |
||||
var msg = 'StorageError [$type, $code]: $message'; |
||||
msg += '\n$stackTrace'; |
||||
return msg; |
||||
} |
||||
} |
||||
|
||||
StorageErrorType _mapDioErrorType(DioErrorType type) { |
||||
switch (type) { |
||||
case DioErrorType.CONNECT_TIMEOUT: |
||||
return StorageErrorType.CONNECT_TIMEOUT; |
||||
case DioErrorType.SEND_TIMEOUT: |
||||
return StorageErrorType.SEND_TIMEOUT; |
||||
case DioErrorType.RECEIVE_TIMEOUT: |
||||
return StorageErrorType.RECEIVE_TIMEOUT; |
||||
case DioErrorType.RESPONSE: |
||||
return StorageErrorType.RESPONSE; |
||||
case DioErrorType.CANCEL: |
||||
return StorageErrorType.CANCEL; |
||||
case DioErrorType.DEFAULT: |
||||
default: |
||||
return StorageErrorType.UNKNOWN; |
||||
} |
||||
} |
@ -0,0 +1,20 @@
|
||||
part of 'put_parts_task.dart'; |
||||
|
||||
/// 分片上传用到的缓存 mixin |
||||
/// |
||||
/// 分片上传的初始化文件、上传分片都应该以此实现缓存控制策略 |
||||
mixin CacheMixin<T> on RequestTask<T> { |
||||
String get _cacheKey; |
||||
|
||||
Future clearCache() async { |
||||
await config.cacheProvider.removeItem(_cacheKey); |
||||
} |
||||
|
||||
Future setCache(String data) async { |
||||
await config.cacheProvider.setItem(_cacheKey, data); |
||||
} |
||||
|
||||
Future<String> getCache() async { |
||||
return await config.cacheProvider.getItem(_cacheKey); |
||||
} |
||||
} |
@ -0,0 +1,51 @@
|
||||
part of 'put_parts_task.dart'; |
||||
|
||||
/// 创建文件,把切片信息合成为一个文件 |
||||
class CompletePartsTask extends RequestTask<PutResponse> { |
||||
final String token; |
||||
final String uploadId; |
||||
final List<Part> parts; |
||||
final String key; |
||||
|
||||
TokenInfo _tokenInfo; |
||||
|
||||
CompletePartsTask({ |
||||
@required this.token, |
||||
@required this.uploadId, |
||||
@required this.parts, |
||||
this.key, |
||||
PutController controller, |
||||
}) : super(controller: controller); |
||||
|
||||
@override |
||||
void preStart() { |
||||
_tokenInfo = Auth.parseUpToken(token); |
||||
super.preStart(); |
||||
} |
||||
|
||||
@override |
||||
Future<PutResponse> createTask() async { |
||||
final bucket = _tokenInfo.putPolicy.getBucket(); |
||||
|
||||
final host = await config.hostProvider.getUpHost( |
||||
bucket: bucket, |
||||
accessKey: _tokenInfo.accessKey, |
||||
); |
||||
final headers = <String, dynamic>{'Authorization': 'UpToken $token'}; |
||||
final encodedKey = key != null ? base64Url.encode(utf8.encode(key)) : '~'; |
||||
final paramUrl = |
||||
'$host/buckets/$bucket/objects/$encodedKey/uploads/$uploadId'; |
||||
|
||||
final response = await client.post<Map<String, dynamic>>( |
||||
paramUrl, |
||||
data: { |
||||
'parts': parts |
||||
..sort((a, b) => a.partNumber - b.partNumber) |
||||
..map((part) => part.toJson()).toList() |
||||
}, |
||||
options: Options(headers: headers), |
||||
); |
||||
|
||||
return PutResponse.fromJson(response.data); |
||||
} |
||||
} |
@ -0,0 +1,92 @@
|
||||
part of 'put_parts_task.dart'; |
||||
|
||||
/// initParts 的返回体 |
||||
class InitParts { |
||||
final int expireAt; |
||||
final String uploadId; |
||||
|
||||
InitParts({ |
||||
@required this.expireAt, |
||||
@required this.uploadId, |
||||
}); |
||||
|
||||
factory InitParts.fromJson(Map json) { |
||||
return InitParts( |
||||
uploadId: json['uploadId'] as String, |
||||
expireAt: json['expireAt'] as int, |
||||
); |
||||
} |
||||
|
||||
Map<String, dynamic> toJson() { |
||||
return <String, dynamic>{ |
||||
'uploadId': uploadId, |
||||
'expireAt': expireAt, |
||||
}; |
||||
} |
||||
} |
||||
|
||||
/// 初始化一个分片上传任务,为 [UploadPartsTask] 提供 uploadId |
||||
class InitPartsTask extends RequestTask<InitParts> with CacheMixin<InitParts> { |
||||
final File file; |
||||
final String token; |
||||
final String key; |
||||
|
||||
@override |
||||
String _cacheKey; |
||||
TokenInfo _tokenInfo; |
||||
|
||||
InitPartsTask({ |
||||
@required this.file, |
||||
@required this.token, |
||||
this.key, |
||||
PutController controller, |
||||
}) : super(controller: controller); |
||||
|
||||
static String getCacheKey(String path, int length, String key) { |
||||
return 'qiniu_dart_sdk_init_parts_task_${path}_key_${key}_size_$length'; |
||||
} |
||||
|
||||
@override |
||||
void preStart() { |
||||
_tokenInfo = Auth.parseUpToken(token); |
||||
_cacheKey = InitPartsTask.getCacheKey(file.path, file.lengthSync(), key); |
||||
super.preStart(); |
||||
} |
||||
|
||||
@override |
||||
Future<InitParts> createTask() async { |
||||
final headers = {'Authorization': 'UpToken $token'}; |
||||
|
||||
final initPartsCache = await getCache(); |
||||
if (initPartsCache != null) { |
||||
return InitParts.fromJson( |
||||
json.decode(initPartsCache) as Map<String, dynamic>); |
||||
} |
||||
|
||||
final bucket = _tokenInfo.putPolicy.getBucket(); |
||||
|
||||
final host = await config.hostProvider.getUpHost( |
||||
bucket: bucket, |
||||
accessKey: _tokenInfo.accessKey, |
||||
); |
||||
|
||||
final encodedKey = key != null ? base64Url.encode(utf8.encode(key)) : '~'; |
||||
final paramUrl = '$host/buckets/$bucket/objects/$encodedKey/uploads'; |
||||
|
||||
final response = await client.post<Map<String, dynamic>>( |
||||
paramUrl, |
||||
|
||||
/// 这里 data 不传,dio 不会触发 cancel 事件 |
||||
data: <String, dynamic>{}, |
||||
options: Options(headers: headers), |
||||
); |
||||
|
||||
return InitParts.fromJson(response.data); |
||||
} |
||||
|
||||
@override |
||||
void postReceive(data) async { |
||||
await setCache(json.encode(data.toJson())); |
||||
super.postReceive(data); |
||||
} |
||||
} |
@ -0,0 +1,26 @@
|
||||
part of 'put_parts_task.dart'; |
||||
|
||||
/// 切片信息 |
||||
class Part { |
||||
final String etag; |
||||
final int partNumber; |
||||
|
||||
Part({ |
||||
@required this.etag, |
||||
@required this.partNumber, |
||||
}); |
||||
|
||||
factory Part.fromJson(Map<String, dynamic> json) { |
||||
return Part( |
||||
etag: json['etag'] as String, |
||||
partNumber: json['partNumber'] as int, |
||||
); |
||||
} |
||||
|
||||
Map<String, dynamic> toJson() { |
||||
return <String, dynamic>{ |
||||
'etag': etag, |
||||
'partNumber': partNumber, |
||||
}; |
||||
} |
||||
} |
@ -0,0 +1,25 @@
|
||||
import '../put_controller.dart'; |
||||
|
||||
class PutByPartOptions { |
||||
/// 资源名 |
||||
/// 如果不传则后端自动生成 |
||||
final String key; |
||||
|
||||
/// 切片大小,单位 MB |
||||
/// |
||||
/// 超出 [partSize] 的文件大小会把每片按照 [partSize] 的大小切片并上传 |
||||
/// 默认 4MB,最小不得小于 1MB,最大不得大于 1024 MB |
||||
final int partSize; |
||||
|
||||
final int maxPartsRequestNumber; |
||||
|
||||
/// 控制器 |
||||
final PutController controller; |
||||
|
||||
const PutByPartOptions({ |
||||
this.key, |
||||
this.partSize = 4, |
||||
this.maxPartsRequestNumber = 5, |
||||
this.controller, |
||||
}); |
||||
} |
@ -0,0 +1,187 @@
|
||||
import 'dart:async'; |
||||
import 'dart:convert'; |
||||
import 'dart:io'; |
||||
import 'dart:math'; |
||||
|
||||
import 'package:dio/dio.dart'; |
||||
import 'package:meta/meta.dart'; |
||||
import 'package:qiniu_sdk_base/qiniu_sdk_base.dart'; |
||||
|
||||
part 'cache_mixin.dart'; |
||||
part 'complete_parts_task.dart'; |
||||
part 'init_parts_task.dart'; |
||||
part 'part.dart'; |
||||
part 'upload_part_task.dart'; |
||||
part 'upload_parts_task.dart'; |
||||
|
||||
/// 分片上传任务 |
||||
class PutByPartTask extends RequestTask<PutResponse> { |
||||
final File file; |
||||
final String token; |
||||
|
||||
final int partSize; |
||||
final int maxPartsRequestNumber; |
||||
|
||||
final String key; |
||||
|
||||
/// 设置为 0,避免子任务重试失败后 [PutByPartTask] 继续重试 |
||||
@override |
||||
int get retryLimit => 0; |
||||
|
||||
PutByPartTask({ |
||||
@required this.file, |
||||
@required this.token, |
||||
@required this.partSize, |
||||
@required this.maxPartsRequestNumber, |
||||
this.key, |
||||
PutController controller, |
||||
}) : assert(file != null), |
||||
assert(token != null), |
||||
assert(partSize != null), |
||||
assert(maxPartsRequestNumber != null), |
||||
assert(() { |
||||
if (partSize < 1 || partSize > 1024) { |
||||
throw RangeError.range(partSize, 1, 1024, 'partSize', |
||||
'partSize must be greater than 1 and less than 1024'); |
||||
} |
||||
return true; |
||||
}()), |
||||
super(controller: controller); |
||||
|
||||
RequestTaskController _currentWorkingTaskController; |
||||
|
||||
@override |
||||
void preStart() { |
||||
super.preStart(); |
||||
|
||||
// 处理相同任务 |
||||
final sameTaskExsist = manager.getTasks().firstWhere( |
||||
(element) => element is PutByPartTask && isEquals(element), |
||||
orElse: () => null, |
||||
); |
||||
|
||||
if (sameTaskExsist != null) { |
||||
throw StorageError( |
||||
type: StorageErrorType.IN_PROGRESS, |
||||
message: '$file 已在上传队列中', |
||||
); |
||||
} |
||||
|
||||
// controller 被取消后取消当前运行的子任务 |
||||
controller?.cancelToken?.whenCancel?.then((_) { |
||||
_currentWorkingTaskController?.cancel(); |
||||
}); |
||||
} |
||||
|
||||
@override |
||||
void postReceive(PutResponse data) { |
||||
_currentWorkingTaskController = null; |
||||
super.postReceive(data); |
||||
} |
||||
|
||||
@override |
||||
Future<PutResponse> createTask() async { |
||||
controller?.notifyStatusListeners(StorageStatus.Request); |
||||
|
||||
final initPartsTask = _createInitParts(); |
||||
final initParts = await initPartsTask.future; |
||||
|
||||
// 初始化任务完成后也告诉外部一个进度 |
||||
controller?.notifyProgressListeners(0.002); |
||||
|
||||
final uploadParts = _createUploadParts(initParts.uploadId); |
||||
|
||||
PutResponse putResponse; |
||||
try { |
||||
final parts = await uploadParts.future; |
||||
putResponse = |
||||
await _createCompleteParts(initParts.uploadId, parts).future; |
||||
} catch (error) { |
||||
// 拿不到 initPartsTask 和 uploadParts 的引用,所以不放到 postError 去 |
||||
if (error is StorageError) { |
||||
/// 满足以下两种情况清理缓存: |
||||
/// 1、如果服务端文件被删除了,清除本地缓存 |
||||
/// 2、如果 uploadId 等参数不对原因会导致 400 |
||||
if (error.code == 612 || error.code == 400) { |
||||
await initPartsTask.clearCache(); |
||||
await uploadParts.clearCache(); |
||||
} |
||||
|
||||
/// 如果服务端文件被删除了,重新上传 |
||||
if (error.code == 612) { |
||||
controller?.notifyStatusListeners(StorageStatus.Retry); |
||||
return createTask(); |
||||
} |
||||
} |
||||
|
||||
rethrow; |
||||
} |
||||
|
||||
/// 上传完成,清除缓存 |
||||
await initPartsTask.clearCache(); |
||||
await uploadParts.clearCache(); |
||||
|
||||
return putResponse; |
||||
} |
||||
|
||||
bool isEquals(PutByPartTask target) { |
||||
return target.file.path == file.path && |
||||
target.key == key && |
||||
target.file.lengthSync() == file.lengthSync(); |
||||
} |
||||
|
||||
/// 初始化上传信息,分片上传的第一步 |
||||
InitPartsTask _createInitParts() { |
||||
final _controller = PutController(); |
||||
|
||||
final task = InitPartsTask( |
||||
file: file, |
||||
token: token, |
||||
key: key, |
||||
controller: _controller, |
||||
); |
||||
|
||||
manager.addTask(task); |
||||
_currentWorkingTaskController = _controller; |
||||
return task; |
||||
} |
||||
|
||||
UploadPartsTask _createUploadParts(String uploadId) { |
||||
final _controller = PutController(); |
||||
|
||||
final task = UploadPartsTask( |
||||
file: file, |
||||
token: token, |
||||
partSize: partSize, |
||||
uploadId: uploadId, |
||||
maxPartsRequestNumber: maxPartsRequestNumber, |
||||
key: key, |
||||
controller: _controller, |
||||
); |
||||
|
||||
_controller.addSendProgressListener(onSendProgress); |
||||
|
||||
manager.addTask(task); |
||||
_currentWorkingTaskController = _controller; |
||||
return task; |
||||
} |
||||
|
||||
/// 创建文件,分片上传的最后一步 |
||||
CompletePartsTask _createCompleteParts( |
||||
String uploadId, |
||||
List<Part> parts, |
||||
) { |
||||
final _controller = PutController(); |
||||
final task = CompletePartsTask( |
||||
token: token, |
||||
uploadId: uploadId, |
||||
parts: parts, |
||||
key: key, |
||||
controller: _controller, |
||||
); |
||||
|
||||
manager.addTask(task); |
||||
_currentWorkingTaskController = _controller; |
||||
return task; |
||||
} |
||||
} |
@ -0,0 +1,112 @@
|
||||
part of 'put_parts_task.dart'; |
||||
|
||||
// 上传一个 part 的任务 |
||||
class UploadPartTask extends RequestTask<UploadPart> { |
||||
final String token; |
||||
final String uploadId; |
||||
final RandomAccessFile raf; |
||||
final int partSize; |
||||
|
||||
// 如果 data 是 Stream 的话,Dio 需要判断 content-length 才会调用 onSendProgress |
||||
// https://github.com/flutterchina/dio/blob/21136168ab39a7536835c7a59ce0465bb05feed4/dio/lib/src/dio.dart#L1000 |
||||
final int byteLength; |
||||
|
||||
final int partNumber; |
||||
|
||||
final String key; |
||||
|
||||
TokenInfo _tokenInfo; |
||||
|
||||
UploadPartTask({ |
||||
@required this.token, |
||||
@required this.raf, |
||||
@required this.uploadId, |
||||
@required this.byteLength, |
||||
@required this.partNumber, |
||||
@required this.partSize, |
||||
this.key, |
||||
PutController controller, |
||||
}) : super(controller: controller); |
||||
|
||||
@override |
||||
void preStart() { |
||||
_tokenInfo = Auth.parseUpToken(token); |
||||
super.preStart(); |
||||
} |
||||
|
||||
@override |
||||
void postReceive(data) { |
||||
controller?.notifyProgressListeners(1); |
||||
super.postReceive(data); |
||||
} |
||||
|
||||
@override |
||||
Future<UploadPart> createTask() async { |
||||
final headers = <String, dynamic>{ |
||||
'Authorization': 'UpToken $token', |
||||
Headers.contentLengthHeader: byteLength, |
||||
}; |
||||
|
||||
final bucket = _tokenInfo.putPolicy.getBucket(); |
||||
|
||||
final host = await config.hostProvider.getUpHost( |
||||
bucket: bucket, |
||||
accessKey: _tokenInfo.accessKey, |
||||
); |
||||
|
||||
final encodedKey = key != null ? base64Url.encode(utf8.encode(key)) : '~'; |
||||
final paramUrl = 'buckets/$bucket/objects/$encodedKey'; |
||||
|
||||
final response = await client.put<Map<String, dynamic>>( |
||||
'$host/$paramUrl/uploads/$uploadId/$partNumber', |
||||
data: Stream.fromIterable([_readFileByPartNumber(partNumber)]), |
||||
// 在 data 是 stream 的场景下, interceptor 传入 cancelToken 这里不传会有 bug |
||||
cancelToken: controller.cancelToken, |
||||
options: Options(headers: headers), |
||||
); |
||||
|
||||
return UploadPart.fromJson(response.data); |
||||
} |
||||
|
||||
// 分片上传是手动从 File 拿一段数据大概 4m(直穿是直接从 File 里面读取) |
||||
// 如果文件是 21m,假设切片是 4 * 5 |
||||
// 外部进度的话会导致一下长到 90% 多,然后变成 100% |
||||
// 解决方法是覆盖父类的 onSendProgress,让 onSendProgress 不处理 Progress 的进度 |
||||
// 改为发送成功后通知(见 postReceive) |
||||
@override |
||||
void onSendProgress(double percent) { |
||||
controller?.notifySendProgressListeners(percent); |
||||
} |
||||
|
||||
// 根据 partNumber 获取要上传的文件片段 |
||||
List<int> _readFileByPartNumber(int partNumber) { |
||||
final startOffset = (partNumber - 1) * partSize * 1024 * 1024; |
||||
raf.setPositionSync(startOffset); |
||||
return raf.readSync(byteLength); |
||||
} |
||||
} |
||||
|
||||
// uploadPart 的返回体 |
||||
class UploadPart { |
||||
final String md5; |
||||
final String etag; |
||||
|
||||
UploadPart({ |
||||
@required this.md5, |
||||
@required this.etag, |
||||
}); |
||||
|
||||
factory UploadPart.fromJson(Map json) { |
||||
return UploadPart( |
||||
md5: json['md5'] as String, |
||||
etag: json['etag'] as String, |
||||
); |
||||
} |
||||
|
||||
Map<String, dynamic> toJson() { |
||||
return <String, dynamic>{ |
||||
'etag': etag, |
||||
'md5': md5, |
||||
}; |
||||
} |
||||
} |
@ -0,0 +1,260 @@
|
||||
part of 'put_parts_task.dart'; |
||||
|
||||
// 批处理上传 parts 的任务,为 [CompletePartsTask] 提供 [Part] |
||||
class UploadPartsTask extends RequestTask<List<Part>> with CacheMixin { |
||||
final File file; |
||||
final String token; |
||||
final String uploadId; |
||||
|
||||
final int partSize; |
||||
final int maxPartsRequestNumber; |
||||
|
||||
final String key; |
||||
|
||||
@override |
||||
String _cacheKey; |
||||
|
||||
/// 设置为 0,避免子任务重试失败后 [UploadPartsTask] 继续重试 |
||||
@override |
||||
int get retryLimit => 0; |
||||
|
||||
// 文件 bytes 长度 |
||||
int _fileByteLength; |
||||
|
||||
// 每个上传分片的字节长度 |
||||
// |
||||
// 文件会按照此长度切片 |
||||
int _partByteLength; |
||||
|
||||
// 文件总共被拆分的分片数 |
||||
int _totalPartCount; |
||||
|
||||
// 上传成功后把 part 信息存起来 |
||||
final Map<int, Part> _uploadedPartMap = {}; |
||||
|
||||
// 处理分片上传任务的 UploadPartTask 的控制器 |
||||
final List<RequestTaskController> _workingUploadPartTaskControllers = []; |
||||
|
||||
// 已发送分片数量 |
||||
int _sentPartCount = 0; |
||||
|
||||
// 已发送到服务器的数量 |
||||
int _sentPartToServerCount = 0; |
||||
|
||||
// 剩余多少被允许的请求数 |
||||
int _idleRequestNumber; |
||||
|
||||
RandomAccessFile _raf; |
||||
|
||||
UploadPartsTask({ |
||||
@required this.file, |
||||
@required this.token, |
||||
@required this.uploadId, |
||||
@required this.partSize, |
||||
@required this.maxPartsRequestNumber, |
||||
this.key, |
||||
PutController controller, |
||||
}) : super(controller: controller); |
||||
|
||||
static String getCacheKey( |
||||
String path, |
||||
int length, |
||||
int partSize, |
||||
String key, |
||||
) { |
||||
final keyList = [ |
||||
'key/$key', |
||||
'path/$path', |
||||
'file_size/$length', |
||||
'part_size/$partSize', |
||||
]; |
||||
|
||||
return 'qiniu_dart_sdk_upload_parts_task@[${keyList..join("/")}]'; |
||||
} |
||||
|
||||
@override |
||||
void preStart() { |
||||
// 当前 controller 被取消后,所有运行中的子任务都需要被取消 |
||||
controller?.cancelToken?.whenCancel?.then((_) { |
||||
for (final controller in _workingUploadPartTaskControllers) { |
||||
controller.cancel(); |
||||
} |
||||
}); |
||||
_fileByteLength = file.lengthSync(); |
||||
_partByteLength = partSize * 1024 * 1024; |
||||
_idleRequestNumber = maxPartsRequestNumber; |
||||
_totalPartCount = (_fileByteLength / _partByteLength).ceil(); |
||||
_cacheKey = getCacheKey(file.path, _fileByteLength, partSize, key); |
||||
// 子任务 UploadPartTask 从 file 去 open 的话虽然上传精度会颗粒更细但是会导致可能读不出文件的问题 |
||||
// 可能 close 没办法立即关闭 file stream,而延迟 close 了,导致某次 open 的 stream 被立即关闭 |
||||
// 所以读不出内容了 |
||||
// 这里改成这里读取一次,子任务从中读取 bytes |
||||
_raf = file.openSync(); |
||||
super.preStart(); |
||||
} |
||||
|
||||
@override |
||||
void postReceive(data) async { |
||||
await _raf.close(); |
||||
super.postReceive(data); |
||||
} |
||||
|
||||
@override |
||||
void postError(Object error) async { |
||||
await _raf.close(); |
||||
// 取消,网络问题等可能导致上传中断,缓存已上传的分片信息 |
||||
await storeUploadedPart(); |
||||
super.postError(error); |
||||
} |
||||
|
||||
Future storeUploadedPart() async { |
||||
if (_uploadedPartMap.isEmpty) { |
||||
return; |
||||
} |
||||
|
||||
await setCache(jsonEncode(_uploadedPartMap.values.toList())); |
||||
} |
||||
|
||||
// 从缓存恢复已经上传的 part |
||||
Future recoverUploadedPart() async { |
||||
// 获取缓存 |
||||
final cachedData = await getCache(); |
||||
// 尝试从缓存恢复 |
||||
if (cachedData != null) { |
||||
var cachedList = <Part>[]; |
||||
|
||||
try { |
||||
final _cachedList = json.decode(cachedData) as List<dynamic>; |
||||
cachedList = _cachedList |
||||
.map((dynamic item) => Part.fromJson(item as Map<String, dynamic>)) |
||||
.toList(); |
||||
} catch (error) { |
||||
rethrow; |
||||
} |
||||
|
||||
for (final part in cachedList) { |
||||
_uploadedPartMap[part.partNumber] = part; |
||||
} |
||||
} |
||||
} |
||||
|
||||
@override |
||||
Future<List<Part>> createTask() async { |
||||
/// 如果已经取消了,直接报错 |
||||
// ignore: null_aware_in_condition |
||||
if (controller != null && controller.cancelToken.isCancelled) { |
||||
throw StorageError(type: StorageErrorType.CANCEL); |
||||
} |
||||
|
||||
controller.notifyStatusListeners(StorageStatus.Request); |
||||
// 尝试恢复缓存,如果有 |
||||
await recoverUploadedPart(); |
||||
|
||||
// 上传分片 |
||||
await _uploadParts(); |
||||
return _uploadedPartMap.values.toList(); |
||||
} |
||||
|
||||
int _uploadingPartIndex = 0; |
||||
|
||||
// 从指定的分片位置往后上传切片 |
||||
Future<void> _uploadParts() async { |
||||
final tasksLength = |
||||
min(_idleRequestNumber, _totalPartCount - _uploadingPartIndex); |
||||
final taskFutures = <Future<Null>>[]; |
||||
|
||||
while (taskFutures.length < tasksLength && |
||||
_uploadingPartIndex < _totalPartCount) { |
||||
// partNumber 按照后端要求必须从 1 开始 |
||||
final partNumber = ++_uploadingPartIndex; |
||||
|
||||
final _uploadedPart = _uploadedPartMap[partNumber]; |
||||
if (_uploadedPart != null) { |
||||
_sentPartCount++; |
||||
_sentPartToServerCount++; |
||||
notifySendProgress(); |
||||
notifyProgress(); |
||||
continue; |
||||
} |
||||
|
||||
final future = _createUploadPartTaskFutureByPartNumber(partNumber); |
||||
taskFutures.add(future); |
||||
} |
||||
|
||||
await Future.wait<Null>(taskFutures); |
||||
} |
||||
|
||||
Future<Null> _createUploadPartTaskFutureByPartNumber(int partNumber) async { |
||||
// 上传分片(part)的字节大小 |
||||
final _byteLength = _getPartSizeByPartNumber(partNumber); |
||||
|
||||
_idleRequestNumber--; |
||||
final _controller = PutController(); |
||||
_workingUploadPartTaskControllers.add(_controller); |
||||
|
||||
final task = UploadPartTask( |
||||
token: token, |
||||
raf: _raf, |
||||
uploadId: uploadId, |
||||
byteLength: _byteLength, |
||||
partNumber: partNumber, |
||||
partSize: partSize, |
||||
key: key, |
||||
controller: _controller, |
||||
); |
||||
|
||||
_controller |
||||
// UploadPartTask 一次上传一个 chunk,通知一次进度 |
||||
..addSendProgressListener((percent) { |
||||
_sentPartCount++; |
||||
notifySendProgress(); |
||||
}) |
||||
// UploadPartTask 上传完成后触发 |
||||
..addProgressListener((percent) { |
||||
_sentPartToServerCount++; |
||||
notifyProgress(); |
||||
}); |
||||
|
||||
manager.addTask(task); |
||||
|
||||
final data = await task.future; |
||||
|
||||
_idleRequestNumber++; |
||||
_uploadedPartMap[partNumber] = |
||||
Part(partNumber: partNumber, etag: data.etag); |
||||
_workingUploadPartTaskControllers.remove(_controller); |
||||
|
||||
await storeUploadedPart(); |
||||
|
||||
// 检查任务是否已经完成 |
||||
if (_uploadedPartMap.length != _totalPartCount) { |
||||
// 上传下一片 |
||||
await _uploadParts(); |
||||
} |
||||
} |
||||
|
||||
// 根据 partNumber 算出当前切片的 byte 大小 |
||||
int _getPartSizeByPartNumber(int partNumber) { |
||||
final startOffset = (partNumber - 1) * _partByteLength; |
||||
|
||||
if (partNumber == _totalPartCount) { |
||||
return _fileByteLength - startOffset; |
||||
} |
||||
|
||||
return _partByteLength; |
||||
} |
||||
|
||||
void notifySendProgress() { |
||||
controller?.notifySendProgressListeners(_sentPartCount / _totalPartCount); |
||||
} |
||||
|
||||
void notifyProgress() { |
||||
controller?.notifyProgressListeners(_sentPartToServerCount / |
||||
_totalPartCount * |
||||
RequestTask.onSendProgressTakePercentOfTotal); |
||||
} |
||||
|
||||
// UploadPartsTask 自身不包含进度,在其他地方处理 |
||||
@override |
||||
void onSendProgress(double percent) {} |
||||
} |
@ -0,0 +1,13 @@
|
||||
import '../put_controller.dart'; |
||||
|
||||
class PutBySingleOptions { |
||||
/// 资源名 |
||||
/// |
||||
/// 如果不传则后端自动生成 |
||||
final String key; |
||||
|
||||
/// 控制器 |
||||
final PutController controller; |
||||
|
||||
const PutBySingleOptions({this.key, this.controller}); |
||||
} |
@ -0,0 +1,60 @@
|
||||
import 'dart:io'; |
||||
|
||||
import 'package:dio/dio.dart'; |
||||
import 'package:meta/meta.dart'; |
||||
import 'package:qiniu_sdk_base/src/storage/task/task.dart'; |
||||
|
||||
import '../../../../auth/auth.dart'; |
||||
import '../put_response.dart'; |
||||
|
||||
// 直传任务 |
||||
class PutBySingleTask extends RequestTask<PutResponse> { |
||||
/// 上传文件 |
||||
final File file; |
||||
|
||||
/// 上传凭证 |
||||
final String token; |
||||
|
||||
/// 资源名 |
||||
/// 如果不传则后端自动生成 |
||||
final String key; |
||||
|
||||
TokenInfo _tokenInfo; |
||||
|
||||
PutBySingleTask({ |
||||
@required this.file, |
||||
@required this.token, |
||||
this.key, |
||||
RequestTaskController controller, |
||||
}) : assert(file != null), |
||||
assert(token != null), |
||||
super(controller: controller); |
||||
|
||||
@override |
||||
void preStart() { |
||||
_tokenInfo = Auth.parseUpToken(token); |
||||
super.preStart(); |
||||
} |
||||
|
||||
@override |
||||
Future<PutResponse> createTask() async { |
||||
final formData = FormData.fromMap(<String, dynamic>{ |
||||
'file': await MultipartFile.fromFile(file.path), |
||||
'token': token, |
||||
'key': key, |
||||
}); |
||||
|
||||
final host = await config.hostProvider.getUpHost( |
||||
accessKey: _tokenInfo.accessKey, |
||||
bucket: _tokenInfo.putPolicy.getBucket(), |
||||
); |
||||
|
||||
final response = await client.post<Map<String, dynamic>>( |
||||
host, |
||||
data: formData, |
||||
cancelToken: controller?.cancelToken, |
||||
); |
||||
|
||||
return PutResponse.fromJson(response.data); |
||||
} |
||||
} |
@ -0,0 +1,5 @@
|
||||
export 'by_part/put_by_part_options.dart'; |
||||
export 'by_single/put_by_single_options.dart'; |
||||
export 'put_controller.dart'; |
||||
export 'put_options.dart'; |
||||
export 'put_response.dart'; |
@ -0,0 +1,3 @@
|
||||
import '../../task/request_task.dart'; |
||||
|
||||
class PutController extends RequestTaskController {} |
@ -0,0 +1,28 @@
|
||||
import 'put_controller.dart'; |
||||
|
||||
class PutOptions { |
||||
/// 资源名 |
||||
/// |
||||
/// 如果不传则后端自动生成 |
||||
final String key; |
||||
|
||||
/// 强制使用单文件上传,不使用分片,默认值 false |
||||
final bool forceBySingle; |
||||
|
||||
/// 使用分片上传时的分片大小,默认值 4,单位为 MB |
||||
final int partSize; |
||||
|
||||
/// 并发上传的队列长度,默认值为 5 |
||||
final int maxPartsRequestNumber; |
||||
|
||||
/// 控制器 |
||||
final PutController controller; |
||||
|
||||
const PutOptions({ |
||||
this.key, |
||||
this.forceBySingle = false, |
||||
this.partSize = 4, |
||||
this.maxPartsRequestNumber = 5, |
||||
this.controller, |
||||
}); |
||||
} |