diff --git a/lib/api/api_server.dart b/lib/api/api_server.dart index 2945da98..badcfc4e 100644 --- a/lib/api/api_server.dart +++ b/lib/api/api_server.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'dart:collection'; import 'dart:core'; import 'dart:io'; +import 'package:dio_cache_interceptor/dio_cache_interceptor.dart'; +import 'package:dio_cache_interceptor_hive_store/dio_cache_interceptor_hive_store.dart'; import 'package:leap_ledger_app/model/common/model.dart'; import 'package:uuid/uuid.dart'; @@ -10,12 +12,11 @@ import 'package:dio/dio.dart' BaseOptions, Dio, DioException, - FormData, InterceptorsWrapper, LogInterceptor, - MultipartFile, Options, QueuedInterceptor, + RequestOptions, Response; import 'package:leap_ledger_app/api/model/model.dart'; @@ -30,6 +31,7 @@ import 'package:leap_ledger_app/model/user/model.dart'; import 'package:leap_ledger_app/routes/routes.dart'; import 'package:leap_ledger_app/util/enter.dart'; import 'package:leap_ledger_app/widget/toast.dart'; +import 'package:web_socket_channel/io.dart'; part 'common.dart'; part 'user.dart'; @@ -50,7 +52,7 @@ const String pubilcBaseUrl = '/public'; class ApiServer { static const _uuid = Uuid(); static Dio dio = Dio(BaseOptions( - baseUrl: Global.config.server.network.address, + baseUrl: Global.config.server.network.httpAddress, headers: {'Content-Type': 'application/json', 'User-Agent': Current.peratingSystem}, )) ..interceptors.add(InterceptorsWrapper( @@ -59,18 +61,18 @@ class ApiServer { return handler.next(options); }, )) - // ..interceptors.add( - // DioCacheInterceptor( - // options: CacheOptions( - // maxStale: const Duration(days: 7), - // keyBuilder: (RequestOptions request) { - // return _uuid.v5(Namespace.url.value, request.uri.toString() + request.data.toString()); - // }, - // store: HiveCacheStore(Global.tempDirectory.path), - // policy: CachePolicy.request, - // ), - // ), - // ) + ..interceptors.add( + DioCacheInterceptor( + options: CacheOptions( + maxStale: const Duration(days: 7), + keyBuilder: (RequestOptions request) { + return _uuid.v5(Namespace.url.value, request.uri.toString() + request.data.toString()); + }, + store: HiveCacheStore(Global.tempDirectory.path), + policy: CachePolicy.request, + ), + ), + ) ..interceptors.add(QueuedInterceptor()) ..interceptors.add(LogInterceptor( request: true, @@ -124,12 +126,12 @@ class ApiServer { Global.hideOverlayLoader(); } if (!logining.isCompleted) { - return getResponseBody(response); + return ResponseBody({'Msg': '请重新登录'}, isSuccess: false); } else if (response.requestOptions.headers[HttpHeaders.authorizationHeader] .toString() .compareTo(UserBloc.token) != 0) { - return getResponseBody(response); + return ResponseBody({'Msg': '请重新登录'}, isSuccess: false); } else { logining = Completer(); return await Global.navigatorKey.currentState!.pushNamed(UserRoutes.login).then((value) { diff --git a/lib/api/product.dart b/lib/api/product.dart index c363f0bb..f4d7c84a 100644 --- a/lib/api/product.dart +++ b/lib/api/product.dart @@ -40,9 +40,9 @@ class ProductApi { return response; } - static Future uploadBill(String productKey, String filePath, {required int accountId}) async { - ResponseBody response = await ApiServer.request(Method.post, '/account/$accountId/product/$productKey/bill/import', - data: FormData.fromMap({'File': await MultipartFile.fromFile(filePath)})); - return response; + static Future wsUploadBill(String productKey, {required int accountId}) async { + var url = Uri.parse( + Global.config.server.network.websocketAddress + "/account/$accountId/product/$productKey/bill/import"); + return await IOWebSocketChannel.connect(url, headers: {HttpHeaders.authorizationHeader: UserBloc.token}); } } diff --git a/lib/bloc/transaction/transaction_bloc.dart b/lib/bloc/transaction/transaction_bloc.dart index c969501f..51e77275 100644 --- a/lib/bloc/transaction/transaction_bloc.dart +++ b/lib/bloc/transaction/transaction_bloc.dart @@ -1,7 +1,6 @@ import 'package:bloc/bloc.dart'; import 'package:leap_ledger_app/api/api_server.dart'; import 'package:leap_ledger_app/bloc/user/config/user_config_bloc.dart'; -import 'package:leap_ledger_app/common/global.dart'; import 'package:leap_ledger_app/model/account/model.dart'; import 'package:leap_ledger_app/model/transaction/model.dart'; import 'package:leap_ledger_app/widget/common/common.dart'; @@ -75,28 +74,14 @@ class TransactionBloc extends Bloc { } bool _verificationData(TransactionEditModel data, emit) { - if (data.categoryId == 0) { - emit(TransactionDataVerificationFails("请选择交易类型")); - return false; - } - if (data.accountId == 0) { - emit(TransactionDataVerificationFails("请选择账本")); - return false; - } - if (data.amount <= 0) { - emit(TransactionDataVerificationFails("金额需大于0")); - return false; - } - if (data.amount > Constant.maxAmount) { - emit(TransactionDataVerificationFails("金额过大")); - return false; - } - if (data.tradeTime.year > Constant.maxYear || data.tradeTime.year < Constant.minYear) { - emit(TransactionDataVerificationFails("时间超过范围")); + var tip = data.check(); + if (tip != null) { + emit(TransactionDataVerificationFails(tip)); return false; + } else { + emit(TransactionDataVerificationSuccess()); + return true; } - emit(TransactionDataVerificationSuccess()); - return true; } _handleShare(TransactionShare event, emit) async { diff --git a/lib/common/global.dart b/lib/common/global.dart index a281a8b4..845f3ac9 100644 --- a/lib/common/global.dart +++ b/lib/common/global.dart @@ -44,7 +44,9 @@ class Global { } enum IncomeExpense { + @JsonValue("income") income(label: "收入"), + @JsonValue("expense") expense(label: "支出"); final String label; diff --git a/lib/config/server.dart b/lib/config/server.dart index 98907088..d68812fc 100644 --- a/lib/config/server.dart +++ b/lib/config/server.dart @@ -9,11 +9,13 @@ class Server { class Network { late final String host; late final String port; - late final String address; + late final String httpAddress; + late final String websocketAddress; Network() { - host = const String.fromEnvironment("config.server.network.host", defaultValue: 'http://10.0.2.2'); + host = const String.fromEnvironment("config.server.network.host", defaultValue: '10.0.2.2'); port = const String.fromEnvironment("config.server.network.port", defaultValue: '8080'); - address = "$host:$port"; + httpAddress = "http://$host:$port"; + websocketAddress = "ws://$host:$port"; } Network.fromJson(dynamic data) { if (data.runtimeType == Map) { diff --git a/lib/model/transaction/model.dart b/lib/model/transaction/model.dart index 5287d7ec..02d31945 100644 --- a/lib/model/transaction/model.dart +++ b/lib/model/transaction/model.dart @@ -1,4 +1,3 @@ - import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:intl/intl.dart'; diff --git a/lib/model/transaction/model.g.dart b/lib/model/transaction/model.g.dart index 54ec978e..e7829e8a 100644 --- a/lib/model/transaction/model.g.dart +++ b/lib/model/transaction/model.g.dart @@ -11,7 +11,7 @@ TransactionEditModel _$TransactionEditModelFromJson(Map json) = userId: (json['UserId'] as num?)?.toInt() ?? 0, accountId: (json['AccountId'] as num?)?.toInt() ?? 0, categoryId: (json['CategoryId'] as num?)?.toInt() ?? 0, - incomeExpense: $enumDecode(_$IncomeExpenseEnumMap, json['IncomeExpense']), + incomeExpense: $enumDecode(_$IncomeExpenseEnumMap, json['IncomeExpense'], unknownValue: IncomeExpense.expense), amount: (json['Amount'] as num?)?.toInt() ?? 0, remark: json['Remark'] as String? ?? '', tradeTime: const UtcDateTimeConverter().fromJson(json['TradeTime'] as String?), @@ -39,7 +39,9 @@ TransactionInfoModel _$TransactionInfoModelFromJson(Map json) = userName: json['UserName'] as String? ?? '', accountId: (json['AccountId'] as num?)?.toInt() ?? 0, accountName: json['AccountName'] as String? ?? '', - incomeExpense: $enumDecodeNullable(_$IncomeExpenseEnumMap, json['IncomeExpense']) ?? IncomeExpense.expense, + incomeExpense: + $enumDecodeNullable(_$IncomeExpenseEnumMap, json['IncomeExpense'], unknownValue: IncomeExpense.expense) ?? + IncomeExpense.expense, categoryId: (json['CategoryId'] as num?)?.toInt() ?? 0, categoryIcon: json['CategoryIcon'] == null ? Json.defaultIconData : Json.iconDataFormJson(json['CategoryIcon']), categoryName: json['CategoryName'] as String? ?? '', @@ -71,7 +73,9 @@ TransactionModel _$TransactionModelFromJson(Map json) => Transa userName: json['UserName'] as String? ?? '', accountId: (json['AccountId'] as num?)?.toInt() ?? 0, accountName: json['AccountName'] as String? ?? '', - incomeExpense: $enumDecodeNullable(_$IncomeExpenseEnumMap, json['IncomeExpense']) ?? IncomeExpense.expense, + incomeExpense: + $enumDecodeNullable(_$IncomeExpenseEnumMap, json['IncomeExpense'], unknownValue: IncomeExpense.expense) ?? + IncomeExpense.expense, categoryId: (json['CategoryId'] as num?)?.toInt() ?? 0, categoryIcon: json['CategoryIcon'] == null ? Json.defaultIconData : Json.iconDataFormJson(json['CategoryIcon']), categoryName: json['CategoryName'] as String? ?? '', diff --git a/lib/model/transaction/transaction.dart b/lib/model/transaction/transaction.dart index b94d9150..4d9598f6 100644 --- a/lib/model/transaction/transaction.dart +++ b/lib/model/transaction/transaction.dart @@ -11,6 +11,7 @@ class TransactionEditModel { late int accountId; @JsonKey(defaultValue: 0) late int categoryId; + @JsonKey(unknownEnumValue: IncomeExpense.expense) late IncomeExpense incomeExpense; @JsonKey(defaultValue: 0) late int amount; @@ -59,6 +60,25 @@ class TransactionEditModel { setLocation(Location l) { tradeTime = TZDateTime.from(tradeTime, l); } + + String? check() { + if (categoryId == 0) { + return "请选择交易类型"; + } + if (accountId == 0) { + return "请选择账本"; + } + if (amount <= 0) { + return "金额需大于0"; + } + if (amount > Constant.maxAmount) { + return "金额过大"; + } + if (tradeTime.year > Constant.maxYear || tradeTime.year < Constant.minYear) { + return "时间超过范围"; + } + return null; + } } /// 交易信息模型 @@ -93,6 +113,9 @@ class TransactionInfoModel extends TransactionEditModel { factory TransactionInfoModel.fromJson(Map json) => _$TransactionInfoModelFromJson(json); Map toJson() => _$TransactionInfoModelToJson(this); + TransactionCategoryBaseModel get categoryBaseModel => TransactionCategoryBaseModel( + id: categoryId, name: categoryName, icon: categoryIcon, incomeExpense: incomeExpense); + TransactionInfoModel.prototypeData() : this(tradeTime: DateTime.now().toLocal()); setAccount(AccountModel account) { accountId = account.id; @@ -165,9 +188,6 @@ class TransactionModel extends TransactionInfoModel { TransactionModel.prototypeData() : this(tradeTime: DateTime.now(), createTime: DateTime.now(), updateTime: DateTime.now()); - TransactionCategoryBaseModel get categoryBaseModel => - TransactionCategoryBaseModel(id: id, name: categoryName, icon: categoryIcon, incomeExpense: incomeExpense); - TransactionShareModel getShareModelByConfig(UserTransactionShareConfigModel config) { TransactionShareModel model = TransactionShareModel( id: id, diff --git a/lib/util/enter.dart b/lib/util/enter.dart index e4ada2de..ea32df5f 100644 --- a/lib/util/enter.dart +++ b/lib/util/enter.dart @@ -1,6 +1,7 @@ import 'dart:typed_data'; import 'dart:io'; import 'dart:convert'; +import 'dart:async'; import 'package:file_picker/file_picker.dart'; import 'package:json_annotation/json_annotation.dart'; @@ -18,3 +19,4 @@ part 'toast.dart'; part 'shared_preferences_cache.dart'; part 'time.dart'; part 'data.dart'; +part 'throttle.dart'; diff --git a/lib/util/throttle.dart b/lib/util/throttle.dart new file mode 100644 index 00000000..2e53cf1d --- /dev/null +++ b/lib/util/throttle.dart @@ -0,0 +1,23 @@ +part of 'enter.dart'; + +class Throttle { + static Map timerMap = {}; + late final Duration duration; + Timer? _timer; + + Throttle({Duration? duration}) { + this.duration = duration ?? Duration(seconds: 2); + } + + void call(String key, void Function() action) { + if (timerMap[key]?.isActive ?? false) return; + timerMap[key] = Timer(duration, () { + _timer = null; + }); + action(); + } + + void dispose() { + _timer?.cancel(); + } +} diff --git a/lib/view/home/home.dart b/lib/view/home/home.dart index a736522c..dd4525d0 100644 --- a/lib/view/home/home.dart +++ b/lib/view/home/home.dart @@ -27,7 +27,6 @@ class _HomeState extends State { @override Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.symmetric(horizontal: Constant.padding, vertical: 0), color: ConstantColor.greyBackground, child: RefreshIndicator( onRefresh: () async => _bloc.add(HomeFetchDataEvent()), @@ -72,15 +71,18 @@ class _HomeState extends State { return Column( mainAxisSize: MainAxisSize.min, children: [ - SafeArea( - child: BlocBuilder( - buildWhen: (_, state) => state is HomeHeaderLoaded, - builder: (context, state) { - if (state is HomeHeaderLoaded) { - return HeaderCard(data: state.data); - } - return HeaderCard(); - }, + Padding( + padding: const EdgeInsets.symmetric(horizontal: Constant.padding, vertical: 0), + child: SafeArea( + child: BlocBuilder( + buildWhen: (_, state) => state is HomeHeaderLoaded, + builder: (context, state) { + if (state is HomeHeaderLoaded) { + return HeaderCard(data: state.data); + } + return HeaderCard(); + }, + ), ), ), SizedBox(height: Constant.margin), @@ -91,34 +93,43 @@ class _HomeState extends State { }, ), SizedBox(height: Constant.margin), - BlocBuilder( - buildWhen: (_, state) => state is HomeTimePeriodStatisticsLoaded, - builder: (context, state) { - if (state is HomeTimePeriodStatisticsLoaded) { - return TimePeriodStatistics( - data: state.data, - ); - } - return TimePeriodStatistics(); - }, + Padding( + padding: const EdgeInsets.symmetric(horizontal: Constant.padding, vertical: 0), + child: BlocBuilder( + buildWhen: (_, state) => state is HomeTimePeriodStatisticsLoaded, + builder: (context, state) { + if (state is HomeTimePeriodStatisticsLoaded) { + return TimePeriodStatistics( + data: state.data, + ); + } + return TimePeriodStatistics(); + }, + ), ), - BlocBuilder( - buildWhen: (_, state) => state is HomeStatisticsLineChart, - builder: (context, state) { - if (state is HomeStatisticsLineChart) { - return StatisticsLineChart(data: state.expenseList); - } - return StatisticsLineChart(data: []); - }, + Padding( + padding: const EdgeInsets.symmetric(horizontal: Constant.padding, vertical: 0), + child: BlocBuilder( + buildWhen: (_, state) => state is HomeStatisticsLineChart, + builder: (context, state) { + if (state is HomeStatisticsLineChart) { + return StatisticsLineChart(data: state.expenseList); + } + return StatisticsLineChart(data: []); + }, + ), ), - BlocBuilder( - buildWhen: (_, state) => state is HomeCategoryAmountRank, - builder: (context, state) { - if (state is HomeCategoryAmountRank) { - return CategoryAmountRank(data: state.rankingList); - } - return CategoryAmountRank(data: []); - }, + Padding( + padding: const EdgeInsets.symmetric(horizontal: Constant.padding, vertical: 0), + child: BlocBuilder( + buildWhen: (_, state) => state is HomeCategoryAmountRank, + builder: (context, state) { + if (state is HomeCategoryAmountRank) { + return CategoryAmountRank(data: state.rankingList); + } + return CategoryAmountRank(data: []); + }, + ), ) ], ); diff --git a/lib/view/transaction/chart/model/category_ranks.dart b/lib/view/transaction/chart/model/category_ranks.dart index 832053d0..d0df8e26 100644 --- a/lib/view/transaction/chart/model/category_ranks.dart +++ b/lib/view/transaction/chart/model/category_ranks.dart @@ -2,6 +2,7 @@ part of 'enter.dart'; class CategoryRankingList { CategoryRankingList({required List data}) { + data = data.where((e) => e.amount > 0).toList(); int totalAmount = 0; for (var element in data) { totalAmount += element.amount; diff --git a/lib/view/transaction/edit/bloc/edit_bloc.dart b/lib/view/transaction/edit/bloc/edit_bloc.dart index 3202a53e..2ea1738a 100644 --- a/lib/view/transaction/edit/bloc/edit_bloc.dart +++ b/lib/view/transaction/edit/bloc/edit_bloc.dart @@ -4,6 +4,7 @@ import 'package:leap_ledger_app/model/account/model.dart'; import 'package:leap_ledger_app/model/transaction/category/model.dart'; import 'package:leap_ledger_app/model/transaction/model.dart'; import 'package:leap_ledger_app/model/user/model.dart'; +import 'package:leap_ledger_app/widget/toast.dart'; import 'package:meta/meta.dart'; part 'edit_event.dart'; @@ -61,7 +62,11 @@ class EditBloc extends AccountBasedBloc { if (event.amount != null) transInfo.amount = event.amount!; if (event.ie != null) transInfo.incomeExpense = event.ie!; var newTrans = transInfo.copy(); - + var checkTip = newTrans.check(); + if (checkTip != null) { + tipToast(checkTip); + return; + } switch (mode) { case TransactionEditMode.update: emit(UpdateTransaction(_originalTrans!, newTrans)); diff --git a/lib/view/transaction/import/bloc/enter.dart b/lib/view/transaction/import/bloc/enter.dart index 4bebbb4a..65c35160 100644 --- a/lib/view/transaction/import/bloc/enter.dart +++ b/lib/view/transaction/import/bloc/enter.dart @@ -1,13 +1,19 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:leap_ledger_app/widget/toast.dart'; +import 'package:path/path.dart' as path; import 'package:bloc/bloc.dart'; import 'package:leap_ledger_app/api/api_server.dart'; import 'package:leap_ledger_app/common/global.dart'; import 'package:leap_ledger_app/model/account/model.dart'; import 'package:leap_ledger_app/model/product/model.dart'; import 'package:leap_ledger_app/model/transaction/category/model.dart'; +import 'package:leap_ledger_app/model/transaction/model.dart'; +import 'package:web_socket_channel/io.dart'; part 'trans_import_tab_bloc.dart'; part 'trans_import_tab_event.dart'; part 'trans_import_tab_state.dart'; -part 'ptc_card_bloc.dart'; -part 'ptc_card_event.dart'; -part 'ptc_card_state.dart'; + +part 'import_cubit.dart'; +part 'import_state.dart'; diff --git a/lib/view/transaction/import/bloc/import_cubit.dart b/lib/view/transaction/import/bloc/import_cubit.dart new file mode 100644 index 00000000..394ccd07 --- /dev/null +++ b/lib/view/transaction/import/bloc/import_cubit.dart @@ -0,0 +1,193 @@ +part of 'enter.dart'; + +class ImportCubit extends Cubit { + late final AccountDetailModel account; + late final ProductModel product; + ImportCubit(this.product, {required this.account}) : super(ImportInitial()); + List ptcList = []; + + fetchData() async { + ResponseBody responseBody = await ProductApi.getTransactionCategory(product.uniqueKey); + ptcList = []; + for (Map data in responseBody.data['List']) { + ptcList.add(ProductTransactionCategoryModel.fromJson(data)); + } + emit(PtcDataLoad()); + } + + bool importing = false; + WebSocket? ws; + int expenseAmount = 0, incomseAmount = 0, expenseCount = 0, incomeCount = 0, ignoreCount = 0; + _beforeImport(File file) async { + importing = true; + expenseAmount = incomseAmount = expenseCount = incomeCount = ignoreCount = 0; + currentId = currentFailTrans = currentFailTip = null; + ws?.sendFile(file); + } + + doImport(File file) async { + if (await file.length() > 128 * 1024) { + tipToast('文件大小超过 128KB'); + } + ws?.close(); + var channel = await ProductApi.wsUploadBill(product.uniqueKey, accountId: account.id); + ws = WebSocket(channel: channel, listenMsg: _listenMsg, listenSignal: (type, data) {}); + _beforeImport(file); + emit(ProgressChanged()); + } + + _listenMsg(MsgType type, Map data) { + switch (type) { + case MsgType.createSuccess: + _transCreate(TransactionModel.fromJson(data)); + case MsgType.createFail: + _transFail(trans: TransactionInfoModel.fromJson(data['Trans']), failTip: data['Msg'], id: data['Id']); + case MsgType.finish: + expenseAmount = data['ExpenseAmount']; + incomseAmount = data['IncomeAmount']; + expenseCount = data['ExpenseCount']; + incomeCount = data['IncomeCount']; + ignoreCount = data['IgnoreCount']; + importing = false; + ws?.close(); + emit(ImportFinished()); + default: + throw new Exception("msg type error"); + } + } + + _transCreate(TransactionModel trans) { + expenseAmount += trans.amount; + expenseCount++; + emit(ProgressChanged()); + } + + Map> _failTrans = {}; + String? currentId; + TransactionInfoModel? currentFailTrans; + String? currentFailTip; + _updateCurrent(String id) { + bool start = false; + if (currentId == null) { + start = true; + } + currentId = id; + currentFailTrans = _failTrans[id]!.first; + currentFailTip = _failTrans[id]!.second; + if (start) { + emit(FailTransProgressing()); + } else { + emit(ProgressingFailTransChanged()); + } + } + + _transFail({required TransactionInfoModel trans, required String failTip, required String id}) { + _failTrans[id] = Pair(trans, failTip); + if (currentId == null) { + _updateCurrent(id); + emit(FailTransProgressing()); + return; + } + emit(TransactionCreateFail(trans: trans, id: id)); + } + + ignoreFailTrans({required String id}) { + if (_failTrans.remove(id) != null) { + ignoreCount++; + emit(ProgressChanged()); + ws?.sendString(type: MsgType.ignoreTrans, data: id); + } + _nextProgressTrans(); + } + + retryCreateTrans({required TransactionInfoModel transInfo, required String id}) { + _failTrans.remove(id); + if (currentId == id) { + _nextProgressTrans(); + } + if (ws == null) { + return; + } + ws?.sendMsg(type: MsgType.createRetry, msg: {"Id": id, "TransInfo": transInfo.toJson()}); + } + + _nextProgressTrans() { + if (_failTrans.keys.firstOrNull != null) + _updateCurrent(_failTrans.keys.first); + else + emit(FailTransProgressFinished()); + } +} + +enum MsgType { + createSuccess(value: "createSuccess"), + createFail(value: "createFail"), + createRetry(value: "createRetry"), + ignoreTrans(value: "ignoreTrans"), + finish(value: "finish"); + + final String value; + const MsgType({ + required this.value, + }); + static MsgType? fromString(String value) { + for (var msgType in MsgType.values) { + if (msgType.value == value) { + return msgType; + } + } + return null; + } +} + +class WebSocket { + WebSocket({required this.channel, required this.listenMsg, required this.listenSignal}) { + this.channel.stream.listen(_listen); + } + final Function(MsgType type, Map msg) listenMsg; + final Function(MsgType type, String data) listenSignal; + final IOWebSocketChannel channel; + + _listen(dynamic str) { + if (str is! String) { + throw new Exception("msg error"); + } + try { + var msg = jsonDecode(str); + if (msg['Type'] == null) { + throw new Exception("msg type error"); + } + var msgType = MsgType.fromString(msg['Type']); + if (msgType == null) return; + if (msg['Data'] is Map) { + listenMsg(msgType, msg['Data']); + } else if (msg['Data'] is String) { + listenSignal(msgType, msg['Data']); + } + } catch (e) {} + } + + sendMsg({required MsgType type, required Map msg}) { + channel.sink.add(jsonEncode({"Type": type.value, "Data": msg})); + } + + sendString({required MsgType type, required String data}) { + channel.sink.add(jsonEncode({"Type": type.value, "Data": data})); + } + + sendFile(File file) { + channel.sink.add(path.basename(file.path)); + channel.sink.add(file.readAsBytesSync()); + } + + close() { + channel.sink.close(); + } +} + +class Pair { + final F first; + final S second; + + Pair(this.first, this.second); +} diff --git a/lib/view/transaction/import/bloc/import_state.dart b/lib/view/transaction/import/bloc/import_state.dart new file mode 100644 index 00000000..3c41b6d6 --- /dev/null +++ b/lib/view/transaction/import/bloc/import_state.dart @@ -0,0 +1,37 @@ +part of 'enter.dart'; + +sealed class ImportState {} + +final class ImportInitial extends ImportState {} + +final class PtcDataLoad extends ImportState { + PtcDataLoad(); +} + +final class Importing extends ImportState {} + +final class TransactionCreateFail extends Importing { + final TransactionInfoModel trans; + final String id; + TransactionCreateFail({required this.trans, required this.id}); +} + +final class ProgressChanged extends Importing { + ProgressChanged(); +} + +final class FailTransProgressing extends Importing { + FailTransProgressing(); +} + +final class FailTransProgressFinished extends Importing { + FailTransProgressFinished(); +} + +final class ProgressingFailTransChanged extends Importing { + ProgressingFailTransChanged(); +} + +final class ImportFinished extends ImportState { + ImportFinished(); +} diff --git a/lib/view/transaction/import/bloc/ptc_card_bloc.dart b/lib/view/transaction/import/bloc/ptc_card_bloc.dart deleted file mode 100644 index a4bcd9fb..00000000 --- a/lib/view/transaction/import/bloc/ptc_card_bloc.dart +++ /dev/null @@ -1,17 +0,0 @@ -part of 'enter.dart'; - -class PtcCardBloc extends Bloc { - final ProductModel product; - PtcCardBloc(this.product) : super(PtcCardInitial()) { - on(_load); - } - _load(FetchPtcList event, Emitter emit) async { - ResponseBody responseBody = await ProductApi.getTransactionCategory(product.uniqueKey); - List ptcList = []; - for (Map data in responseBody.data['List']) { - ProductTransactionCategoryModel model = ProductTransactionCategoryModel.fromJson(data); - ptcList.add(model); - } - emit(PtcCardLoad(ptcList)); - } -} diff --git a/lib/view/transaction/import/bloc/ptc_card_event.dart b/lib/view/transaction/import/bloc/ptc_card_event.dart deleted file mode 100644 index f868aba9..00000000 --- a/lib/view/transaction/import/bloc/ptc_card_event.dart +++ /dev/null @@ -1,5 +0,0 @@ -part of 'enter.dart'; - -sealed class PtcCardEvent {} - -final class FetchPtcList extends PtcCardEvent {} diff --git a/lib/view/transaction/import/bloc/ptc_card_state.dart b/lib/view/transaction/import/bloc/ptc_card_state.dart deleted file mode 100644 index 460c0c63..00000000 --- a/lib/view/transaction/import/bloc/ptc_card_state.dart +++ /dev/null @@ -1,10 +0,0 @@ -part of 'enter.dart'; - -sealed class PtcCardState {} - -final class PtcCardInitial extends PtcCardState {} - -final class PtcCardLoad extends PtcCardState { - final List ptcList; - PtcCardLoad(this.ptcList); -} diff --git a/lib/view/transaction/import/bloc/trans_import_tab_bloc.dart b/lib/view/transaction/import/bloc/trans_import_tab_bloc.dart index fd6efe31..ee5b3163 100644 --- a/lib/view/transaction/import/bloc/trans_import_tab_bloc.dart +++ b/lib/view/transaction/import/bloc/trans_import_tab_bloc.dart @@ -3,7 +3,6 @@ part of 'enter.dart'; class TransImportTabBloc extends Bloc { TransImportTabBloc({required this.account}) : super(TransImportTabInitial()) { on(loadTab); - on(uploadFile); } final AccountDetailModel account; final List _list = []; @@ -38,13 +37,4 @@ class TransImportTabBloc extends Bloc emit(TransImportTabLoaded(_list, _tree)); } } - - uploadFile(TransactionImportUploadBillEvent event, Emitter emit) async { - ResponseBody responseBody = await ProductApi.uploadBill( - event.product.uniqueKey, - event.filePath, - accountId: account.id, - ); - if (responseBody.isSuccess) {} - } } diff --git a/lib/view/transaction/import/bloc/trans_import_tab_event.dart b/lib/view/transaction/import/bloc/trans_import_tab_event.dart index 4c7ef5cd..87db9c17 100644 --- a/lib/view/transaction/import/bloc/trans_import_tab_event.dart +++ b/lib/view/transaction/import/bloc/trans_import_tab_event.dart @@ -3,9 +3,3 @@ part of 'enter.dart'; sealed class TransImportTabEvent {} final class TransImportTabLoadedEvent extends TransImportTabEvent {} - -final class TransactionImportUploadBillEvent extends TransImportTabEvent { - final ProductModel product; - final String filePath; - TransactionImportUploadBillEvent(this.product, this.filePath); -} diff --git a/lib/view/transaction/import/transaction_import.dart b/lib/view/transaction/import/transaction_import.dart index 9ce2765e..93c41b7c 100644 --- a/lib/view/transaction/import/transaction_import.dart +++ b/lib/view/transaction/import/transaction_import.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart'; import 'package:leap_ledger_app/common/global.dart'; import 'package:leap_ledger_app/model/account/model.dart'; import 'package:leap_ledger_app/model/product/model.dart'; @@ -8,9 +9,13 @@ import 'package:leap_ledger_app/routes/routes.dart'; import 'package:leap_ledger_app/util/enter.dart'; import 'package:file_picker/file_picker.dart'; +import 'package:leap_ledger_app/widget/amount/enter.dart'; +import 'package:leap_ledger_app/widget/category/enter.dart'; import 'bloc/enter.dart'; part 'widget/ptc_card.dart'; +part 'widget/exec_card.dart'; +part 'widget/fail_dialog.dart'; class TransactionImport extends StatefulWidget { const TransactionImport({super.key, required this.account}); @@ -39,10 +44,11 @@ class _TransactionImportState extends State { length: state.list.length, child: Scaffold( appBar: AppBar( - title: const Text('导入账单'), - bottom: TabBar( - tabs: state.list.map((product) => Tab(text: product.name)).toList(), - )), + title: const Text('导入账单'), + bottom: TabBar( + tabs: state.list.map((product) => Tab(text: product.name)).toList(), + ), + ), body: buildPage(context, state), ), ); @@ -61,49 +67,30 @@ class _TransactionImportState extends State { bucket: PageStorageBucket(), child: TabBarView( children: List.generate(state.list.length, (index) { - return _buidlButtonGroup(context, state.list[index], state.tree); + return DecoratedBox( + decoration: BoxDecoration(color: ConstantColor.greyBackground), + child: Padding( + padding: const EdgeInsets.all(Constant.margin), + child: ExecCard(product: state.list[index], categoryTree: state.tree), + ), + ); }), ), ); } +} - Widget _buidlButtonGroup(BuildContext context, ProductModel product, - List>> categoryTree) { - return Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - BlocProvider( - create: (context) => PtcCardBloc(product)..add(FetchPtcList()), - child: PtcCard(categoryTree), - ), - SizedBox( - width: 250, - child: ElevatedButton( - style: ButtonStyle( - shape: WidgetStateProperty.all(const StadiumBorder(side: BorderSide(style: BorderStyle.none)))), - onPressed: () { - _uploadBillFile(product, context); - }, - child: Text( - "导 入", - style: TextStyle( - fontSize: Theme.of(context).primaryTextTheme.titleMedium!.fontSize, - ), - ), - ), - ), - ], +class _Func { + _Func(); + static Card _buildCard({required Widget child}) { + return Card( + color: Colors.white, + margin: const EdgeInsets.all(Constant.margin), + elevation: 0, + shape: const RoundedRectangleBorder( + borderRadius: ConstantDecoration.borderRadius, ), + child: child, ); } - - void _uploadBillFile(ProductModel product, BuildContext context) async { - await FileOperation.selectFile(FileType.custom, ['xls', 'xlsx', 'csv']).then((value) { - if (value != null) { - BlocProvider.of(context).add(TransactionImportUploadBillEvent(product, value.path)); - } - }); - } } diff --git a/lib/view/transaction/import/widget/exec_card.dart b/lib/view/transaction/import/widget/exec_card.dart new file mode 100644 index 00000000..6e347859 --- /dev/null +++ b/lib/view/transaction/import/widget/exec_card.dart @@ -0,0 +1,153 @@ +part of '../transaction_import.dart'; + +class ExecCard extends StatefulWidget { + const ExecCard({super.key, required this.product, required this.categoryTree}); + final ProductModel product; + final List>> categoryTree; + @override + State createState() => _ExecCardState(); +} + +class _ExecCardState extends State { + late final ImportCubit _cubit; + @override + void initState() { + var account = BlocProvider.of(context).account; + _cubit = ImportCubit(widget.product, account: account)..fetchData(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _cubit, + child: BlocListener( + listenWhen: (_, state) => state is FailTransProgressing, + listener: (context, state) { + showDialog(context: context, builder: (context) => FailDialog(_cubit)); + }, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + BlocBuilder( + buildWhen: (_, state) => state is PtcDataLoad, + builder: (_, state) { + return PtcCard( + _cubit.ptcList, + categoryTree: widget.categoryTree, + account: _cubit.account, + product: _cubit.product, + ); + }, + ), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + BlocBuilder( + builder: (context, state) { + if (!_cubit.importing) return _buildImportButtom(); + return SizedBox(); + }, + ), + _buildImportingCard(), + ], + ), + ) + ], + ), + ), + ); + } + + Widget _buildImportingCard() { + return _Func._buildCard( + child: BlocBuilder( + buildWhen: (_, state) => state is ProgressChanged || state is ImportFinished, + builder: (context, state) { + if (state is! Importing && state is! ImportFinished) { + return SizedBox(); + } + List children = []; + if (state is ProgressChanged || state is ImportFinished) { + children.add(DefaultTextStyle.merge( + style: const TextStyle(fontSize: 18), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + Text.rich(TextSpan(children: [ + TextSpan(text: "支:"), + AmountTextSpan.sameHeight(_cubit.expenseAmount, + incomeExpense: IncomeExpense.expense, displayModel: IncomeExpenseDisplayModel.symbols), + TextSpan(text: "(${_cubit.expenseCount}笔)") + ])), + Text.rich(TextSpan(children: [ + TextSpan(text: "收:"), + AmountTextSpan.sameHeight(_cubit.incomseAmount, + incomeExpense: IncomeExpense.income, displayModel: IncomeExpenseDisplayModel.symbols), + TextSpan(text: "(${_cubit.incomeCount}笔)") + ])), + ], + ))); + } + List header = []; + if (state is ImportFinished) + header.add( + Center(child: Icon(Icons.done_rounded, color: ConstantColor.primaryColor, size: Constant.iconlargeSize))); + if (_cubit.importing) header.add(Center(child: CircularProgressIndicator())); + if (_cubit.ignoreCount > 0) { + header.add(Text("忽略${_cubit.ignoreCount}笔")); + } + return Padding( + padding: const EdgeInsets.all(Constant.padding), + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (header.length > 0) + Padding( + padding: const EdgeInsets.only(bottom: Constant.margin), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: header, + ), + ), + ...children, + ], + ), + ); + }, + )); + } + + Widget _buildImportButtom() { + return SizedBox( + width: 250, + child: ElevatedButton( + style: + ButtonStyle(shape: WidgetStateProperty.all(const StadiumBorder(side: BorderSide(style: BorderStyle.none)))), + onPressed: () { + _uploadBillFile(_cubit.product, context); + }, + child: Text( + "导入", + style: TextStyle( + letterSpacing: Constant.padding * 2, + fontSize: Theme.of(context).primaryTextTheme.titleMedium!.fontSize, + ), + ), + ), + ); + } + + void _uploadBillFile(ProductModel product, BuildContext context) async { + Throttle().call('uploadBillFile', () async { + await FileOperation.selectFile(FileType.custom, ['xls', 'xlsx', 'csv']).then((value) { + if (value != null) { + _cubit.doImport(value); + } + }); + }); + } +} diff --git a/lib/view/transaction/import/widget/fail_dialog.dart b/lib/view/transaction/import/widget/fail_dialog.dart new file mode 100644 index 00000000..36140a9e --- /dev/null +++ b/lib/view/transaction/import/widget/fail_dialog.dart @@ -0,0 +1,112 @@ +part of '../transaction_import.dart'; + +class FailDialog extends StatefulWidget { + FailDialog(this.cubit); + final ImportCubit cubit; + @override + _FailDialogState createState() => _FailDialogState(); +} + +class _FailDialogState extends State { + late final ImportCubit _cubit; + + @override + void initState() { + _cubit = widget.cubit; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: _cubit, + child: AlertDialog( + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + BlocListener( + listener: (context, state) { + if (state is ProgressingFailTransChanged) + setState(() {}); + else if (state is FailTransProgressFinished) Navigator.pop(context); + }, + child: AnimatedSwitcher( + duration: Duration(milliseconds: 200), + child: _buildContent(), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => _cubit.ignoreFailTrans(id: _cubit.currentId!), + child: Text('忽略'), + ), + TextButton( + onPressed: () async { + var page = TransactionRoutes.editNavigator(context, + mode: TransactionEditMode.popTrans, account: _cubit.account, transInfo: _cubit.currentFailTrans); + await page.push(); + var transInfo = page.getPopTransInfo(); + if (transInfo == null) { + _cubit.ignoreFailTrans(id: _cubit.currentId!); + } + _cubit.retryCreateTrans(id: _cubit.currentId!, transInfo: transInfo!); + }, + child: Text('修改'), + ), + ], + ), + ); + } + + Widget _buildContent() { + if (_cubit.currentFailTrans == null) { + return SizedBox(); + } + final trans = _cubit.currentFailTrans!; + final tip = _cubit.currentFailTip; + return Padding( + padding: const EdgeInsets.all(Constant.padding), + child: Column( + mainAxisSize: MainAxisSize.max, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: Constant.margin), + child: LargeCategoryIconAndName(trans.categoryBaseModel), + ), + Padding( + padding: const EdgeInsets.all(Constant.margin), + child: AmountText.sameHeight( + trans.amount, + textStyle: TextStyle(fontSize: 24, fontWeight: FontWeight.w500), + )), + _buildInfoWidget(labal: "时间", content: DateFormat.yMd().add_Hms().format(trans.tradeTime)), + _buildInfoWidget(labal: "备注", content: trans.remark.isEmpty ? "无" : trans.remark), + if (tip != null) + Padding( + padding: const EdgeInsets.only(top: Constant.margin), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline_rounded), + Text( + tip, + style: TextStyle(fontSize: ConstantFontSize.bodyLarge), + ) + ], + ), + ) + ], + ), + ); + } + + Widget _buildInfoWidget({required String labal, required String content}) { + return Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [Text(labal), Text(content)], + ); + } +} diff --git a/lib/view/transaction/import/widget/ptc_card.dart b/lib/view/transaction/import/widget/ptc_card.dart index 7e1a5d9f..ee0ecab9 100644 --- a/lib/view/transaction/import/widget/ptc_card.dart +++ b/lib/view/transaction/import/widget/ptc_card.dart @@ -2,41 +2,47 @@ part of '../transaction_import.dart'; class PtcCard extends StatelessWidget { final List>> categoryTree; - const PtcCard(this.categoryTree, {super.key}); + final AccountDetailModel account; + final ProductModel product; + final List ptcList; + const PtcCard(this.ptcList, {required this.categoryTree, super.key, required this.account, required this.product}); @override Widget build(BuildContext context) { - return BlocBuilder(builder: ((context, state) { - var account = BlocProvider.of(context).account; - var product = BlocProvider.of(context).product; - if (state is PtcCardLoad) { - return Container( - decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(4)), - margin: EdgeInsets.zero, - child: Column(children: [ - GridView.builder( - shrinkWrap: true, // 让网格视图适应内容大小 - physics: const NeverScrollableScrollPhysics(), // 禁止滚动 - padding: EdgeInsets.zero, - itemCount: state.ptcList.length, - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 4, - mainAxisSpacing: 0, - crossAxisSpacing: 12, - childAspectRatio: 2.5, - ), - itemBuilder: (BuildContext context, int index) { - return buildItem(state.ptcList[index]); - }), - Padding( + return Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _Func._buildCard( + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + padding: EdgeInsets.zero, + itemCount: ptcList.length, + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + mainAxisSpacing: 0, + crossAxisSpacing: 12, + childAspectRatio: 2.5, + ), + itemBuilder: (BuildContext context, int index) { + return buildItem(ptcList[index]); + }, + )), + BlocBuilder( + builder: (context, state) { + if (state is Importing) { + return SizedBox(); + } + return Padding( padding: const EdgeInsets.all(Constant.margin), child: Offstage( - offstage: false == - TransactionCategoryRouterGuard.productMapping( - account: account, - product: product, - categoryTree: categoryTree, - ptcList: state.ptcList, - ), + offstage: ptcList.length == 0 || + false == + TransactionCategoryRouterGuard.productMapping( + account: account, + product: product, + categoryTree: categoryTree, + ptcList: ptcList, + ), child: ElevatedButton( onPressed: () { TransactionCategoryRoutes.productMappingNavigator( @@ -44,17 +50,16 @@ class PtcCard extends StatelessWidget { account: account, product: product, categoryTree: categoryTree, - ptcList: state.ptcList, + ptcList: ptcList, ).push(); }, child: const Text('设置交易类型关联', style: TextStyle(fontSize: ConstantFontSize.headline))), ), - ) - ]), - ); - } - return const SizedBox(height: 300, child: Center(child: CircularProgressIndicator())); - })); + ); + }, + ) + ], + ); } Widget buildItem(ProductTransactionCategoryModel model) { diff --git a/lib/widget/category/category_icon_and_name.dart b/lib/widget/category/category_icon_and_name.dart index 17232841..17af0fd8 100644 --- a/lib/widget/category/category_icon_and_name.dart +++ b/lib/widget/category/category_icon_and_name.dart @@ -29,14 +29,15 @@ class CategoryIconAndName extends Statel ), ), ), - SizedBox(height: Constant.margin / 2), - Text( - category.name, - style: TextStyle( - fontSize: ConstantFontSize.bodySmall, - color: isSelected ? ConstantColor.primaryColor : Colors.black, - ), - ) + if (category.name.isNotEmpty) SizedBox(height: Constant.margin / 2), + if (category.name.isNotEmpty) + Text( + category.name, + style: TextStyle( + fontSize: ConstantFontSize.bodySmall, + color: isSelected ? ConstantColor.primaryColor : Colors.black, + ), + ) ], ); if (onTap != null) { @@ -60,21 +61,19 @@ class LargeCategoryIconAndName extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.center, children: [ DecoratedBox( - decoration: BoxDecoration( - color: ConstantColor.primaryColor, - borderRadius: BorderRadius.circular(90), - ), + decoration: BoxDecoration(color: ConstantColor.primaryColor, borderRadius: BorderRadius.circular(90)), child: SizedBox( width: 48, height: 48, child: Icon(category.icon, size: 28, color: Colors.white), ), ), - SizedBox(height: Constant.margin / 2), - Text( - category.name, - style: TextStyle(fontSize: ConstantFontSize.bodySmall, color: ConstantColor.primaryColor), - ) + if (category.name.isNotEmpty) SizedBox(height: Constant.margin / 2), + if (category.name.isNotEmpty) + Text( + category.name, + style: TextStyle(fontSize: ConstantFontSize.bodySmall, color: ConstantColor.primaryColor), + ) ], ); } diff --git a/pubspec.yaml b/pubspec.yaml index f122aada..56ef1c39 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -14,17 +14,12 @@ dependencies: sdk: flutter flutter_localizations: sdk: flutter - bloc: ^8.1.0 - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. - cupertino_icons: ^1.0.2 + bloc: ^8.1.0 flutter_bloc: ^8.1.0 fl_chart: ^0.69.0 intl: ^0.19.0 drag_and_drop_lists: ^0.4.1 - material_segmented_control: ^5.0.0 - yaml: ^3.1.2 fluttertoast: ^8.2.2 shared_preferences: ^2.0.6 dio: ^5.7.0 @@ -44,9 +39,9 @@ dependencies: timezone: ^0.9.3 url_launcher: ^6.2.6 auto_size_text: ^3.0.0 - geolocator: ^13.0.1 - flutter_timezone: 3.0.1 + flutter_timezone: ^3.0.1 device_info_plus: ^10.1.2 + web_socket_channel: ^3.0.1 dev_dependencies: flutter_test: sdk: flutter