From d562bd0e0c257d79f0f9ecaacd2d5b01c12ff7eb Mon Sep 17 00:00:00 2001 From: Serious-senpai <57554044+Serious-senpai@users.noreply.github.com> Date: Wed, 27 Nov 2024 11:08:33 +0700 Subject: [PATCH] Add resident payment page --- app/resident_manager/lib/src/config.dart | 2 +- app/resident_manager/lib/src/models/fee.dart | 44 + .../lib/src/models/payment.dart | 25 + .../lib/src/models/payment_status.dart | 52 ++ .../lib/src/translations.dart | 27 + .../lib/src/widgets/admin/home.dart | 42 +- .../lib/src/widgets/admin/reg_queue.dart | 18 +- .../lib/src/widgets/admin/residents.dart | 638 +++++++------- .../lib/src/widgets/admin/rooms.dart | 781 +++++++++--------- .../lib/src/widgets/common.dart | 10 - .../lib/src/widgets/payment.dart | 259 +++++- .../lib/src/widgets/utils.dart | 27 + server/v1/models/payment_status.py | 6 +- server/v1/routes/residents/pay.py | 11 +- 14 files changed, 1156 insertions(+), 786 deletions(-) create mode 100644 app/resident_manager/lib/src/models/fee.dart create mode 100644 app/resident_manager/lib/src/models/payment.dart create mode 100644 app/resident_manager/lib/src/models/payment_status.dart diff --git a/app/resident_manager/lib/src/config.dart b/app/resident_manager/lib/src/config.dart index 61a9dbb..e769a16 100644 --- a/app/resident_manager/lib/src/config.dart +++ b/app/resident_manager/lib/src/config.dart @@ -1,4 +1,4 @@ final epoch = DateTime.utc(2024, 1, 1); -const int DB_PAGINATION_QUERY = 50; +const DB_PAGINATION_QUERY = 50; const DEFAULT_ADMIN_USERNAME = "admin"; const DEFAULT_ADMIN_PASSWORD = "NgaiLongGey"; diff --git a/app/resident_manager/lib/src/models/fee.dart b/app/resident_manager/lib/src/models/fee.dart new file mode 100644 index 0000000..fa33cc5 --- /dev/null +++ b/app/resident_manager/lib/src/models/fee.dart @@ -0,0 +1,44 @@ +import "snowflake.dart"; +import "../utils.dart"; + +class Fee with Snowflake { + @override + final int id; + + final String name; + final double lower; + final double upper; + final double perArea; + final double perMotorbike; + final double perCar; + final Date deadline; + final String description; + final int flags; + + Fee({ + required this.id, + required this.name, + required this.lower, + required this.upper, + required this.perArea, + required this.perMotorbike, + required this.perCar, + required this.deadline, + required this.description, + required this.flags, + }); + + Fee.fromJson(dynamic data) + : this( + id: data["id"] as int, + name: data["name"] as String, + lower: data["lower"] as double, + upper: data["upper"] as double, + perArea: data["per_area"] as double, + perMotorbike: data["per_motorbike"] as double, + perCar: data["per_car"] as double, + deadline: Date.parse(data["deadline"] as String)!, + description: data["description"] as String, + flags: data["flags"] as int, + ); +} diff --git a/app/resident_manager/lib/src/models/payment.dart b/app/resident_manager/lib/src/models/payment.dart new file mode 100644 index 0000000..9289f65 --- /dev/null +++ b/app/resident_manager/lib/src/models/payment.dart @@ -0,0 +1,25 @@ +import "snowflake.dart"; + +class Payment with Snowflake { + @override + final int id; + + final int room; + final double amount; + final int feeId; + + Payment({ + required this.id, + required this.room, + required this.amount, + required this.feeId, + }); + + Payment.fromJson(dynamic data) + : this( + id: data["id"] as int, + room: data["room"] as int, + amount: data["amount"] as double, + feeId: data["fee_id"] as int, + ); +} diff --git a/app/resident_manager/lib/src/models/payment_status.dart b/app/resident_manager/lib/src/models/payment_status.dart new file mode 100644 index 0000000..b3eb526 --- /dev/null +++ b/app/resident_manager/lib/src/models/payment_status.dart @@ -0,0 +1,52 @@ +import "dart:convert"; + +import "fee.dart"; +import "payment.dart"; +import "results.dart"; +import "../state.dart"; + +class PaymentStatus { + final Fee fee; + final double lowerBound; + final double upperBound; + final Payment? payment; + + PaymentStatus({ + required this.fee, + required this.lowerBound, + required this.upperBound, + required this.payment, + }); + + PaymentStatus.fromJson(dynamic data) + : this( + fee: Fee.fromJson(data["fee"]), + lowerBound: data["lower_bound"] as double, + upperBound: data["upper_bound"] as double, + payment: data["payment"] == null ? null : Payment.fromJson(data["payment"]), + ); + + static Future?>> query({ + required ApplicationState state, + required int offset, + required DateTime createdFrom, + required DateTime createdTo, + }) async { + final response = await state.get( + "/api/v1/residents/fee", + queryParameters: { + "offset": offset.toString(), + "created_from": createdFrom.toUtc().toIso8601String(), + "created_to": createdTo.toUtc().toIso8601String(), + }, + ); + final result = json.decode(utf8.decode(response.bodyBytes)); + + if (response.statusCode == 200) { + final data = result["data"] as List; + return Result(0, List.from(data.map(PaymentStatus.fromJson))); + } + + return Result(result["code"], null); + } +} diff --git a/app/resident_manager/lib/src/translations.dart b/app/resident_manager/lib/src/translations.dart index e2218e8..97e54e3 100644 --- a/app/resident_manager/lib/src/translations.dart +++ b/app/resident_manager/lib/src/translations.dart @@ -80,6 +80,15 @@ class AppLocale { static const String Payment = "Payment"; static const String Admin = "Admin"; static const String NotYetLoggedIn = "NotYetLoggedIn"; + static const String NoData = "NoData"; + static const String AmountPaid = "AmountPaid"; + static const String NotPaid = "NotPaid"; + static const String Deadline = "Deadline"; + static const String PaymentHistory = "PaymentHistory"; + static const String Fee = "Fee"; + static const String Description = "Description"; + static const String Minimum = "Minimum"; + static const String Maximum = "Maximum"; // Error codes static const String Error0 = "Error0"; @@ -181,6 +190,15 @@ class AppLocale { Payment: "Payment", Admin: "Admin", NotYetLoggedIn: "Not yet logged in", + NoData: "No data available.", + AmountPaid: "Amount paid", + NotPaid: "Not paid", + Deadline: "Deadline", + PaymentHistory: "Payment history", + Fee: "Fee", + Description: "Description", + Minimum: "Minimum", + Maximum: "Maximum", // Error codes Error0: "Operation completed successfully.", @@ -283,6 +301,15 @@ class AppLocale { Payment: "Thanh toán", Admin: "Quản trị viên", NotYetLoggedIn: "Chưa đăng nhập", + NoData: "Không có dữ liệu.", + AmountPaid: "Số tiền đã trả", + NotPaid: "Chưa thanh toán", + Deadline: "Hạn thanh toán", + PaymentHistory: "Lịch sử thanh toán", + Fee: "Phí", + Description: "Mô tả", + Minimum: "Tối thiểu", + Maximum: "Tối đa", // Error codes Error0: "Thao tác thành công.", diff --git a/app/resident_manager/lib/src/widgets/admin/home.dart b/app/resident_manager/lib/src/widgets/admin/home.dart index e3d68a0..caa59e4 100644 --- a/app/resident_manager/lib/src/widgets/admin/home.dart +++ b/app/resident_manager/lib/src/widgets/admin/home.dart @@ -1,10 +1,10 @@ import "package:flutter/material.dart"; import "package:flutter_localization/flutter_localization.dart"; import "package:one_clock/one_clock.dart"; -import "package:resident_manager/src/routes.dart"; import "../common.dart"; import "../state.dart"; +import "../../routes.dart"; import "../../translations.dart"; import "../../utils.dart"; @@ -23,31 +23,27 @@ class _CardBuilder { _CardBuilder({this.backgroundColor, this.onPressed, required this.children}); Widget call(int flex) { - return Builder( - builder: (context) { - return _ShrinkableFlex( - flex: flex, - child: Container( + return _ShrinkableFlex( + flex: flex, + child: Container( + padding: const EdgeInsets.all(5), + child: ElevatedButton( + onPressed: onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: backgroundColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + child: Padding( padding: const EdgeInsets.all(5), - child: ElevatedButton( - onPressed: onPressed, - style: ElevatedButton.styleFrom( - backgroundColor: backgroundColor, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), - ), - ), - child: Padding( - padding: const EdgeInsets.all(5), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: children, - ), - ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: children, ), ), - ); - }, + ), + ), ); } } diff --git a/app/resident_manager/lib/src/widgets/admin/reg_queue.dart b/app/resident_manager/lib/src/widgets/admin/reg_queue.dart index cff64a9..b195285 100644 --- a/app/resident_manager/lib/src/widgets/admin/reg_queue.dart +++ b/app/resident_manager/lib/src/widgets/admin/reg_queue.dart @@ -9,6 +9,7 @@ import "package:flutter_localization/flutter_localization.dart"; import "../common.dart"; import "../state.dart"; +import "../utils.dart"; import "../../config.dart"; import "../../state.dart"; import "../../translations.dart"; @@ -204,22 +205,7 @@ class _RegisterQueuePageState extends AbstractCommonState wit initialData: queryLoader.lastData, builder: (context, _) { if (queryLoader.isLoading) { - return SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox.square( - dimension: 50, - child: CircularProgressIndicator(), - ), - const SizedBox.square(dimension: 5), - Text(AppLocale.Loading.getString(context)), - ], - ), - ), - ); + return const SliverCircularProgressFullScreen(); } final code = queryLoader.lastData; diff --git a/app/resident_manager/lib/src/widgets/admin/residents.dart b/app/resident_manager/lib/src/widgets/admin/residents.dart index 3aaad44..f890a77 100644 --- a/app/resident_manager/lib/src/widgets/admin/residents.dart +++ b/app/resident_manager/lib/src/widgets/admin/residents.dart @@ -9,6 +9,7 @@ import "package:flutter_localization/flutter_localization.dart"; import "../common.dart"; import "../state.dart"; +import "../utils.dart"; import "../../config.dart"; import "../../translations.dart"; import "../../utils.dart"; @@ -386,366 +387,347 @@ class _ResidentsPageState extends AbstractCommonState with Common sliver: FutureBuilder( future: queryLoader.future, builder: (context, snapshot) { - switch (snapshot.connectionState) { - case ConnectionState.none: - case ConnectionState.waiting: - case ConnectionState.active: - return SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox.square( - dimension: 50, - child: CircularProgressIndicator(), - ), - const SizedBox.square(dimension: 5), - Text(AppLocale.Loading.getString(context)), - ], + if (queryLoader.isLoading) { + return const SliverCircularProgressFullScreen(); + } + + final code = snapshot.data; + if (code == 0) { + TableCell headerCeil(String text, [String? newOrderBy]) { + if (newOrderBy != null) { + if (queryLoader.orderBy == newOrderBy) { + text += queryLoader.ascending ? " ▴" : " ▾"; + } else { + text += " ▴▾"; + } + } + + return TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: GestureDetector( + onTap: () { + if (newOrderBy != null) { + if (newOrderBy == queryLoader.orderBy) { + queryLoader.ascending = !queryLoader.ascending; + } else { + queryLoader.ascending = true; + } + + queryLoader.orderBy = newOrderBy; + pagination.offset = 0; + reload(); + } + }, + child: Text(text, style: const TextStyle(fontWeight: FontWeight.bold)), ), ), ); + } + + final rows = [ + TableRow( + decoration: const BoxDecoration(border: BorderDirectional(bottom: BorderSide(width: 1))), + children: [ + TableCell( + child: Checkbox.adaptive( + value: queryLoader.selected.containsAll(queryLoader.residents), + onChanged: (state) { + if (state != null) { + if (state) { + queryLoader.selected.addAll(queryLoader.residents); + } else { + queryLoader.selected.removeAll(queryLoader.residents); + } + } - case ConnectionState.done: - final code = snapshot.data; - if (code == 0) { - TableCell headerCeil(String text, [String? newOrderBy]) { - if (newOrderBy != null) { - if (queryLoader.orderBy == newOrderBy) { - text += queryLoader.ascending ? " ▴" : " ▾"; - } else { - text += " ▴▾"; - } - } - - return TableCell( - child: Padding( - padding: const EdgeInsets.all(5), - child: GestureDetector( - onTap: () { - if (newOrderBy != null) { - if (newOrderBy == queryLoader.orderBy) { - queryLoader.ascending = !queryLoader.ascending; + refresh(); + }, + ), + ), + headerCeil(AppLocale.Fullname.getString(context), "name"), + headerCeil(AppLocale.Room.getString(context), "room"), + headerCeil(AppLocale.DateOfBirth.getString(context)), + headerCeil(AppLocale.Phone.getString(context)), + headerCeil(AppLocale.Email.getString(context)), + headerCeil(AppLocale.CreationTime.getString(context), "id"), + headerCeil(AppLocale.Username.getString(context), "username"), + headerCeil(AppLocale.Option.getString(context)), + ], + ), + ...List.from( + queryLoader.residents.map( + (resident) => TableRow( + children: [ + Checkbox.adaptive( + value: queryLoader.selected.contains(resident), + onChanged: (state) { + if (state != null) { + if (state) { + queryLoader.selected.add(resident); } else { - queryLoader.ascending = true; + queryLoader.selected.remove(resident); } - - queryLoader.orderBy = newOrderBy; - pagination.offset = 0; - reload(); } + + refresh(); }, - child: Text(text, style: const TextStyle(fontWeight: FontWeight.bold)), ), - ), - ); - } - - final rows = [ - TableRow( - decoration: const BoxDecoration(border: BorderDirectional(bottom: BorderSide(width: 1))), - children: [ TableCell( - child: Checkbox.adaptive( - value: queryLoader.selected.containsAll(queryLoader.residents), - onChanged: (state) { - if (state != null) { - if (state) { - queryLoader.selected.addAll(queryLoader.residents); - } else { - queryLoader.selected.removeAll(queryLoader.residents); - } - } - - refresh(); - }, + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(resident.name), ), ), - headerCeil(AppLocale.Fullname.getString(context), "name"), - headerCeil(AppLocale.Room.getString(context), "room"), - headerCeil(AppLocale.DateOfBirth.getString(context)), - headerCeil(AppLocale.Phone.getString(context)), - headerCeil(AppLocale.Email.getString(context)), - headerCeil(AppLocale.CreationTime.getString(context), "id"), - headerCeil(AppLocale.Username.getString(context), "username"), - headerCeil(AppLocale.Option.getString(context)), - ], - ), - ...List.from( - queryLoader.residents.map( - (resident) => TableRow( - children: [ - Checkbox.adaptive( - value: queryLoader.selected.contains(resident), - onChanged: (state) { - if (state != null) { - if (state) { - queryLoader.selected.add(resident); - } else { - queryLoader.selected.remove(resident); - } - } - - refresh(); - }, - ), - TableCell( - child: Padding( - padding: const EdgeInsets.all(5), - child: Text(resident.name), - ), - ), - TableCell( - child: Padding( - padding: const EdgeInsets.all(5), - child: Text(resident.room.toString()), - ), - ), - TableCell( - child: Padding( - padding: const EdgeInsets.all(5), - child: Text(resident.birthday?.format("dd/mm/yyyy") ?? "---"), - ), - ), - TableCell( - child: Padding( - padding: const EdgeInsets.all(5), - child: Text(resident.phone ?? "---"), - ), - ), - TableCell( - child: Padding( - padding: const EdgeInsets.all(5), - child: Text(resident.email ?? "---"), - ), - ), - TableCell( - child: Padding( - padding: const EdgeInsets.all(5), - child: Text(resident.createdAt.toLocal().toString()), - ), - ), - TableCell( - child: Padding( - padding: const EdgeInsets.all(5), - child: Text(resident.username ?? "---"), - ), - ), - TableCell( - child: Padding( - padding: const EdgeInsets.all(5), - child: Row( - children: [_EditButton(resident, this)], - ), - ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(resident.room.toString()), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(resident.birthday?.format("dd/mm/yyyy") ?? "---"), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(resident.phone ?? "---"), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(resident.email ?? "---"), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(resident.createdAt.toLocal().toString()), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(resident.username ?? "---"), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Row( + children: [_EditButton(resident, this)], ), - ], + ), ), - ), + ], ), - ]; - - return SliverMainAxisGroup( - slivers: [ - SliverPadding( - padding: const EdgeInsets.all(5), - sliver: SliverToBoxAdapter( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - TextButton.icon( - icon: const Icon(Icons.delete_outlined), - label: Text("${AppLocale.DeleteAccount.getString(context)} (${queryLoader.selected.length})"), - onPressed: _actionLock.locked || queryLoader.selected.isEmpty ? null : _deleteAccounts, - ), - ], + ), + ), + ]; + + return SliverMainAxisGroup( + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(5), + sliver: SliverToBoxAdapter( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextButton.icon( + icon: const Icon(Icons.delete_outlined), + label: Text("${AppLocale.DeleteAccount.getString(context)} (${queryLoader.selected.length})"), + onPressed: _actionLock.locked || queryLoader.selected.isEmpty ? null : _deleteAccounts, ), - ), + ], ), - SliverPadding( - padding: const EdgeInsets.all(5), - sliver: SliverToBoxAdapter( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconButton( - icon: const Icon(Icons.chevron_left_outlined), - onPressed: () { - if (pagination.offset > 0) { - pagination.offset--; - reload(); - } - }, - ), - FutureBuilder( - future: pagination.future, - builder: (context, _) { - final offset = pagination.offset, offsetLimit = pagination.offsetLimit; - return Text("${offset + 1}/${max(offset, offsetLimit) + 1}"); - }, - ), - IconButton( - icon: const Icon(Icons.chevron_right_outlined), - onPressed: () { - if (pagination.offset < pagination.offsetLimit) { - pagination.offset++; - reload(); - } - }, - ), - IconButton( - icon: const Icon(Icons.refresh_outlined), - onPressed: () { - pagination.offset = 0; - reload(); - }, - ), - TextButton.icon( - icon: const Icon(Icons.search_outlined), - label: Text( - search.searching ? AppLocale.Searching.getString(context) : AppLocale.Search.getString(context), - style: TextStyle(decoration: search.searching ? TextDecoration.underline : null), - ), - onPressed: () async { - // Save current values for restoration - final nameSearch = search.name.text; - final roomSearch = search.room.text; - final usernameSearch = search.username.text; - - final formKey = GlobalKey(); - - void onSubmit(BuildContext context) { - Navigator.pop(context, true); - pagination.offset = 0; - reload(); - } - - final submitted = await showDialog( - context: context, - builder: (context) => SimpleDialog( - contentPadding: const EdgeInsets.all(10), - title: Text(AppLocale.Search.getString(context)), - children: [ - Form( - key: formKey, - autovalidateMode: AutovalidateMode.onUserInteraction, - child: Column( + ), + ), + SliverPadding( + padding: const EdgeInsets.all(5), + sliver: SliverToBoxAdapter( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: const Icon(Icons.chevron_left_outlined), + onPressed: () { + if (pagination.offset > 0) { + pagination.offset--; + reload(); + } + }, + ), + FutureBuilder( + future: pagination.future, + builder: (context, _) { + final offset = pagination.offset, offsetLimit = pagination.offsetLimit; + return Text("${offset + 1}/${max(offset, offsetLimit) + 1}"); + }, + ), + IconButton( + icon: const Icon(Icons.chevron_right_outlined), + onPressed: () { + if (pagination.offset < pagination.offsetLimit) { + pagination.offset++; + reload(); + } + }, + ), + IconButton( + icon: const Icon(Icons.refresh_outlined), + onPressed: () { + pagination.offset = 0; + reload(); + }, + ), + TextButton.icon( + icon: const Icon(Icons.search_outlined), + label: Text( + search.searching ? AppLocale.Searching.getString(context) : AppLocale.Search.getString(context), + style: TextStyle(decoration: search.searching ? TextDecoration.underline : null), + ), + onPressed: () async { + // Save current values for restoration + final nameSearch = search.name.text; + final roomSearch = search.room.text; + final usernameSearch = search.username.text; + + final formKey = GlobalKey(); + + void onSubmit(BuildContext context) { + Navigator.pop(context, true); + pagination.offset = 0; + reload(); + } + + final submitted = await showDialog( + context: context, + builder: (context) => SimpleDialog( + contentPadding: const EdgeInsets.all(10), + title: Text(AppLocale.Search.getString(context)), + children: [ + Form( + key: formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + child: Column( + children: [ + TextFormField( + controller: search.name, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8.0), + icon: const Icon(Icons.badge_outlined), + label: Text(AppLocale.Fullname.getString(context)), + ), + onFieldSubmitted: (_) => onSubmit(context), + validator: (value) => nameValidator(context, required: false, value: value), + ), + TextFormField( + controller: search.room, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8.0), + icon: const Icon(Icons.room_outlined), + label: Text(AppLocale.Room.getString(context)), + ), + onFieldSubmitted: (_) => onSubmit(context), + validator: (value) => roomValidator(context, required: false, value: value), + ), + TextFormField( + controller: search.username, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8.0), + icon: const Icon(Icons.person_outlined), + label: Text(AppLocale.Username.getString(context)), + ), + onFieldSubmitted: (_) => onSubmit(context), + validator: (value) => usernameValidator(context, required: false, value: value), + ), + const SizedBox.square(dimension: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ - TextFormField( - controller: search.name, - decoration: InputDecoration( - contentPadding: const EdgeInsets.all(8.0), - icon: const Icon(Icons.badge_outlined), - label: Text(AppLocale.Fullname.getString(context)), - ), - onFieldSubmitted: (_) => onSubmit(context), - validator: (value) => nameValidator(context, required: false, value: value), - ), - TextFormField( - controller: search.room, - decoration: InputDecoration( - contentPadding: const EdgeInsets.all(8.0), - icon: const Icon(Icons.room_outlined), - label: Text(AppLocale.Room.getString(context)), + Expanded( + child: TextButton.icon( + icon: const Icon(Icons.done_outlined), + label: Text(AppLocale.Search.getString(context)), + onPressed: () { + if (formKey.currentState?.validate() ?? false) { + onSubmit(context); + } + }, ), - onFieldSubmitted: (_) => onSubmit(context), - validator: (value) => roomValidator(context, required: false, value: value), ), - TextFormField( - controller: search.username, - decoration: InputDecoration( - contentPadding: const EdgeInsets.all(8.0), - icon: const Icon(Icons.person_outlined), - label: Text(AppLocale.Username.getString(context)), + Expanded( + child: TextButton.icon( + icon: const Icon(Icons.clear_outlined), + label: Text(AppLocale.ClearAll.getString(context)), + onPressed: () { + search.name.clear(); + search.room.clear(); + search.username.clear(); + + onSubmit(context); + }, ), - onFieldSubmitted: (_) => onSubmit(context), - validator: (value) => usernameValidator(context, required: false, value: value), - ), - const SizedBox.square(dimension: 10), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: TextButton.icon( - icon: const Icon(Icons.done_outlined), - label: Text(AppLocale.Search.getString(context)), - onPressed: () { - if (formKey.currentState?.validate() ?? false) { - onSubmit(context); - } - }, - ), - ), - Expanded( - child: TextButton.icon( - icon: const Icon(Icons.clear_outlined), - label: Text(AppLocale.ClearAll.getString(context)), - onPressed: () { - search.name.clear(); - search.room.clear(); - search.username.clear(); - - onSubmit(context); - }, - ), - ), - ], ), ], ), - ), - ], + ], + ), ), - ); - - if (submitted == null) { - // Dialog dismissed. Restore field values - search.name.text = nameSearch; - search.room.text = roomSearch; - search.username.text = usernameSearch; - } - }, - ), - ], + ], + ), + ); + + if (submitted == null) { + // Dialog dismissed. Restore field values + search.name.text = nameSearch; + search.room.text = roomSearch; + search.username.text = usernameSearch; + } + }, ), - ), + ], ), - SliverToBoxAdapter(child: Center(child: _notification)), - SliverPadding( - padding: const EdgeInsets.all(5), - sliver: SliverToBoxAdapter( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Container( - width: max(mediaQuery.size.width, 1000), - padding: const EdgeInsets.all(5), - child: Table(children: rows), - ), - ), + ), + ), + SliverToBoxAdapter(child: Center(child: _notification)), + SliverPadding( + padding: const EdgeInsets.all(5), + sliver: SliverToBoxAdapter( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Container( + width: max(mediaQuery.size.width, 1000), + padding: const EdgeInsets.all(5), + child: Table(children: rows), ), ), - ], - ); - } - - return SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox.square( - dimension: 50, - child: Icon(Icons.highlight_off_outlined), - ), - const SizedBox.square(dimension: 5), - Text((code == null ? AppLocale.ConnectionError : AppLocale.errorMessage(code)).getString(context)), - ], ), ), - ); + ], + ); } + + return SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox.square( + dimension: 50, + child: Icon(Icons.highlight_off_outlined), + ), + const SizedBox.square(dimension: 5), + Text((code == null ? AppLocale.ConnectionError : AppLocale.errorMessage(code)).getString(context)), + ], + ), + ), + ); }, ), ); diff --git a/app/resident_manager/lib/src/widgets/admin/rooms.dart b/app/resident_manager/lib/src/widgets/admin/rooms.dart index 15626d5..7cf2804 100644 --- a/app/resident_manager/lib/src/widgets/admin/rooms.dart +++ b/app/resident_manager/lib/src/widgets/admin/rooms.dart @@ -8,6 +8,7 @@ import "package:flutter_localization/flutter_localization.dart"; import "../common.dart"; import "../state.dart"; +import "../utils.dart"; import "../../config.dart"; import "../../routes.dart"; import "../../translations.dart"; @@ -143,256 +144,182 @@ class _RoomsPageState extends AbstractCommonState with CommonStateMix title: Text(AppLocale.RoomsList.getString(context), style: const TextStyle(fontWeight: FontWeight.bold)), sliver: FutureBuilder( future: queryLoader.future, + initialData: queryLoader.lastData, builder: (context, snapshot) { - switch (snapshot.connectionState) { - case ConnectionState.none: - case ConnectionState.waiting: - case ConnectionState.active: - return SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox.square( - dimension: 50, - child: CircularProgressIndicator(), - ), - const SizedBox.square(dimension: 5), - Text(AppLocale.Loading.getString(context)), - ], - ), + if (queryLoader.isLoading) { + return const SliverCircularProgressFullScreen(); + } + + final code = snapshot.data; + if (code == 0) { + TableCell headerCeil(String text) { + return TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(text, style: const TextStyle(fontWeight: FontWeight.bold)), ), ); - - case ConnectionState.done: - final code = snapshot.data; - if (code == 0) { - TableCell headerCeil(String text) { - return TableCell( - child: Padding( - padding: const EdgeInsets.all(5), - child: Text(text, style: const TextStyle(fontWeight: FontWeight.bold)), - ), - ); - } - - final rows = [ - TableRow( - decoration: const BoxDecoration(border: BorderDirectional(bottom: BorderSide(width: 1))), + } + + final rows = [ + TableRow( + decoration: const BoxDecoration(border: BorderDirectional(bottom: BorderSide(width: 1))), + children: [ + headerCeil(AppLocale.Room.getString(context)), + headerCeil(AppLocale.Floor.getString(context)), + headerCeil(AppLocale.Area1.getString(context)), + headerCeil(AppLocale.MotorbikesCount.getString(context)), + headerCeil(AppLocale.CarsCount.getString(context)), + headerCeil(AppLocale.ResidentsCount.getString(context)), + headerCeil(AppLocale.Option.getString(context)), + ], + ), + ...List.from( + queryLoader.rooms.map( + (room) => TableRow( children: [ - headerCeil(AppLocale.Room.getString(context)), - headerCeil(AppLocale.Floor.getString(context)), - headerCeil(AppLocale.Area1.getString(context)), - headerCeil(AppLocale.MotorbikesCount.getString(context)), - headerCeil(AppLocale.CarsCount.getString(context)), - headerCeil(AppLocale.ResidentsCount.getString(context)), - headerCeil(AppLocale.Option.getString(context)), - ], - ), - ...List.from( - queryLoader.rooms.map( - (room) => TableRow( - children: [ - TableCell( - child: Padding( - padding: const EdgeInsets.all(5), - child: Text(room.room.toString()), - ), - ), - TableCell( - child: Padding( - padding: const EdgeInsets.all(5), - child: Text(room.floor.toString()), - ), - ), - TableCell( - child: Padding( - padding: const EdgeInsets.all(5), - child: Text(room.area?.toString() ?? "---"), - ), - ), - TableCell( - child: Padding( - padding: const EdgeInsets.all(5), - child: Text(room.motorbike?.toString() ?? "---"), - ), - ), - TableCell( - child: Padding( - padding: const EdgeInsets.all(5), - child: Text(room.car?.toString() ?? "---"), - ), - ), - TableCell( - child: Padding( - padding: const EdgeInsets.all(5), - child: Text(room.residents.toString()), - ), - ), - TableCell( - child: Padding( - padding: const EdgeInsets.all(5), - child: Row( - children: [ - IconButton( - icon: const Icon(Icons.search_outlined), - onPressed: () async { - state.extras["room-search"] = room; - await pushNamedAndRefresh(context, ApplicationRoute.adminResidentsPage); - }, - ), - IconButton( - icon: const Icon(Icons.edit_outlined), - onPressed: () async { - final roomController = TextEditingController(text: room.room.toString()); - final areaController = TextEditingController(text: room.area?.toString()); - final motorbikeController = TextEditingController(text: room.motorbike?.toString()); - final carController = TextEditingController(text: room.car?.toString()); - - final formKey = GlobalKey(); - final submitted = await showDialog( - context: context, - builder: (context) => SimpleDialog( - contentPadding: const EdgeInsets.all(10), - title: Text(AppLocale.EditRoomInfo.getString(context)), - children: [ - Form( - key: formKey, - autovalidateMode: AutovalidateMode.onUserInteraction, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - TextFormField( - controller: roomController, - decoration: InputDecoration( - contentPadding: const EdgeInsets.all(8.0), - label: FieldLabel( - AppLocale.Room.getString(context), - style: const TextStyle(color: Colors.black), - required: true, - ), - ), - enabled: false, - validator: (value) => roomValidator(context, required: true, value: value), - ), - TextFormField( - controller: areaController, - decoration: InputDecoration( - contentPadding: const EdgeInsets.all(8.0), - label: FieldLabel( - AppLocale.Area1.getString(context), - style: const TextStyle(color: Colors.black), - required: true, - ), - ), - validator: (value) => roomAreaValidator(context, required: true, value: value), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(room.room.toString()), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(room.floor.toString()), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(room.area?.toString() ?? "---"), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(room.motorbike?.toString() ?? "---"), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(room.car?.toString() ?? "---"), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(room.residents.toString()), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Row( + children: [ + IconButton( + icon: const Icon(Icons.search_outlined), + onPressed: () async { + state.extras["room-search"] = room; + await pushNamedAndRefresh(context, ApplicationRoute.adminResidentsPage); + }, + ), + IconButton( + icon: const Icon(Icons.edit_outlined), + onPressed: () async { + final roomController = TextEditingController(text: room.room.toString()); + final areaController = TextEditingController(text: room.area?.toString()); + final motorbikeController = TextEditingController(text: room.motorbike?.toString()); + final carController = TextEditingController(text: room.car?.toString()); + + final formKey = GlobalKey(); + final submitted = await showDialog( + context: context, + builder: (context) => SimpleDialog( + contentPadding: const EdgeInsets.all(10), + title: Text(AppLocale.EditRoomInfo.getString(context)), + children: [ + Form( + key: formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + TextFormField( + controller: roomController, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8.0), + label: FieldLabel( + AppLocale.Room.getString(context), + style: const TextStyle(color: Colors.black), + required: true, ), - TextFormField( - controller: motorbikeController, - decoration: InputDecoration( - contentPadding: const EdgeInsets.all(8.0), - label: FieldLabel( - AppLocale.MotorbikesCount.getString(context), - style: const TextStyle(color: Colors.black), - required: true, - ), - ), - validator: (value) => motorbikesCountValidator(context, required: true, value: value), + ), + enabled: false, + validator: (value) => roomValidator(context, required: true, value: value), + ), + TextFormField( + controller: areaController, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8.0), + label: FieldLabel( + AppLocale.Area1.getString(context), + style: const TextStyle(color: Colors.black), + required: true, ), - TextFormField( - controller: carController, - decoration: InputDecoration( - contentPadding: const EdgeInsets.all(8.0), - label: FieldLabel( - AppLocale.CarsCount.getString(context), - style: const TextStyle(color: Colors.black), - required: true, - ), - ), - validator: (value) => carsCountValidator(context, required: true, value: value), + ), + validator: (value) => roomAreaValidator(context, required: true, value: value), + ), + TextFormField( + controller: motorbikeController, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8.0), + label: FieldLabel( + AppLocale.MotorbikesCount.getString(context), + style: const TextStyle(color: Colors.black), + required: true, ), - const SizedBox.square(dimension: 10), - Container( - padding: const EdgeInsets.all(5), - width: double.infinity, - child: TextButton.icon( - icon: const Icon(Icons.done_outlined), - label: Text(AppLocale.Confirm.getString(context)), - onPressed: () { - if (formKey.currentState?.validate() ?? false) { - Navigator.pop(context, true); - } - }, - ), + ), + validator: (value) => motorbikesCountValidator(context, required: true, value: value), + ), + TextFormField( + controller: carController, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8.0), + label: FieldLabel( + AppLocale.CarsCount.getString(context), + style: const TextStyle(color: Colors.black), + required: true, ), - ], + ), + validator: (value) => carsCountValidator(context, required: true, value: value), ), - ), - ], + const SizedBox.square(dimension: 10), + Container( + padding: const EdgeInsets.all(5), + width: double.infinity, + child: TextButton.icon( + icon: const Icon(Icons.done_outlined), + label: Text(AppLocale.Confirm.getString(context)), + onPressed: () { + if (formKey.currentState?.validate() ?? false) { + Navigator.pop(context, true); + } + }, + ), + ), + ], + ), ), - ); + ], + ), + ); - if (submitted != null) { - final check = formKey.currentState?.validate() ?? false; - if (check) { - await _actionLock.run( - () async { - _notification = Builder( - builder: (context) => Text( - AppLocale.Loading.getString(context), - style: const TextStyle(color: Colors.blue), - ), - ); - refresh(); - - try { - final result = await RoomData.update( - state: state, - rooms: [ - RoomData( - room: room.room, - area: double.parse(areaController.text), - motorbike: int.parse(motorbikeController.text), - car: int.parse(carController.text), - ), - ], - ); - - if (result != null) { - _notification = Builder( - builder: (context) => Text( - AppLocale.errorMessage(result.code).getString(context), - style: const TextStyle(color: Colors.red), - ), - ); - } else { - _notification = const SizedBox.shrink(); - } - } catch (e) { - await showToastSafe(msg: context.mounted ? AppLocale.ConnectionError.getString(context) : AppLocale.ConnectionError); - _notification = Builder( - builder: (context) => Text( - AppLocale.ConnectionError.getString(context), - style: const TextStyle(color: Colors.red), - ), - ); - - if (!(e is SocketException || e is TimeoutException)) { - rethrow; - } - } finally { - reload(); - } - }, - ); - } - } - }, - ), - IconButton( - icon: const Icon(Icons.delete_outlined), - onPressed: () async { + if (submitted != null) { + final check = formKey.currentState?.validate() ?? false; + if (check) { await _actionLock.run( () async { _notification = Builder( @@ -404,7 +331,18 @@ class _RoomsPageState extends AbstractCommonState with CommonStateMix refresh(); try { - final result = await room.delete(state: state); + final result = await RoomData.update( + state: state, + rooms: [ + RoomData( + room: room.room, + area: double.parse(areaController.text), + motorbike: int.parse(motorbikeController.text), + car: int.parse(carController.text), + ), + ], + ); + if (result != null) { _notification = Builder( builder: (context) => Text( @@ -432,185 +370,230 @@ class _RoomsPageState extends AbstractCommonState with CommonStateMix } }, ); + } + } + }, + ), + IconButton( + icon: const Icon(Icons.delete_outlined), + onPressed: () async { + await _actionLock.run( + () async { + _notification = Builder( + builder: (context) => Text( + AppLocale.Loading.getString(context), + style: const TextStyle(color: Colors.blue), + ), + ); + refresh(); + + try { + final result = await room.delete(state: state); + if (result != null) { + _notification = Builder( + builder: (context) => Text( + AppLocale.errorMessage(result.code).getString(context), + style: const TextStyle(color: Colors.red), + ), + ); + } else { + _notification = const SizedBox.shrink(); + } + } catch (e) { + await showToastSafe(msg: context.mounted ? AppLocale.ConnectionError.getString(context) : AppLocale.ConnectionError); + _notification = Builder( + builder: (context) => Text( + AppLocale.ConnectionError.getString(context), + style: const TextStyle(color: Colors.red), + ), + ); + + if (!(e is SocketException || e is TimeoutException)) { + rethrow; + } + } finally { + reload(); + } }, - ), - ], + ); + }, ), - ), + ], ), - ], + ), ), - ), + ], ), - ]; - - return SliverMainAxisGroup( - slivers: [ - SliverPadding( - padding: const EdgeInsets.all(5), - sliver: SliverToBoxAdapter( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconButton( - icon: const Icon(Icons.chevron_left_outlined), - onPressed: () { - if (pagination.offset > 0) { - pagination.offset--; - reload(); - } - }, - ), - FutureBuilder( - future: pagination.future, - builder: (context, _) { - final offset = pagination.offset, offsetLimit = pagination.offsetLimit; - return Text("${offset + 1}/${max(offset, offsetLimit) + 1}"); - }, - ), - IconButton( - icon: const Icon(Icons.chevron_right_outlined), - onPressed: () { - if (pagination.offset < pagination.offsetLimit) { - pagination.offset++; - reload(); - } - }, - ), - IconButton( - icon: const Icon(Icons.refresh_outlined), - onPressed: () { - pagination.offset = 0; - reload(); - }, - ), - TextButton.icon( - icon: const Icon(Icons.search_outlined), - label: Text( - search.searching ? AppLocale.Searching.getString(context) : AppLocale.Search.getString(context), - style: TextStyle(decoration: search.searching ? TextDecoration.underline : null), - ), - onPressed: () async { - // Save current values for restoration - final roomSearch = search.room.text; - final floorSearch = search.floor.text; - - final formKey = GlobalKey(); - - void onSubmit(BuildContext context) { - Navigator.pop(context, true); - pagination.offset = 0; - reload(); - } - - final submitted = await showDialog( - context: context, - builder: (context) => SimpleDialog( - contentPadding: const EdgeInsets.all(10), - title: Text(AppLocale.Search.getString(context)), - children: [ - Form( - key: formKey, - autovalidateMode: AutovalidateMode.onUserInteraction, - child: Column( + ), + ), + ]; + + return SliverMainAxisGroup( + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(5), + sliver: SliverToBoxAdapter( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: const Icon(Icons.chevron_left_outlined), + onPressed: () { + if (pagination.offset > 0) { + pagination.offset--; + reload(); + } + }, + ), + FutureBuilder( + future: pagination.future, + builder: (context, _) { + final offset = pagination.offset, offsetLimit = pagination.offsetLimit; + return Text("${offset + 1}/${max(offset, offsetLimit) + 1}"); + }, + ), + IconButton( + icon: const Icon(Icons.chevron_right_outlined), + onPressed: () { + if (pagination.offset < pagination.offsetLimit) { + pagination.offset++; + reload(); + } + }, + ), + IconButton( + icon: const Icon(Icons.refresh_outlined), + onPressed: () { + pagination.offset = 0; + reload(); + }, + ), + TextButton.icon( + icon: const Icon(Icons.search_outlined), + label: Text( + search.searching ? AppLocale.Searching.getString(context) : AppLocale.Search.getString(context), + style: TextStyle(decoration: search.searching ? TextDecoration.underline : null), + ), + onPressed: () async { + // Save current values for restoration + final roomSearch = search.room.text; + final floorSearch = search.floor.text; + + final formKey = GlobalKey(); + + void onSubmit(BuildContext context) { + Navigator.pop(context, true); + pagination.offset = 0; + reload(); + } + + final submitted = await showDialog( + context: context, + builder: (context) => SimpleDialog( + contentPadding: const EdgeInsets.all(10), + title: Text(AppLocale.Search.getString(context)), + children: [ + Form( + key: formKey, + autovalidateMode: AutovalidateMode.onUserInteraction, + child: Column( + children: [ + TextFormField( + controller: search.room, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8.0), + icon: const Icon(Icons.room_outlined), + label: Text(AppLocale.Room.getString(context)), + ), + onFieldSubmitted: (_) => onSubmit(context), + validator: (value) => roomValidator(context, required: false, value: value), + ), + TextFormField( + controller: search.floor, + decoration: InputDecoration( + contentPadding: const EdgeInsets.all(8.0), + icon: const Icon(Icons.apartment_outlined), + label: Text(AppLocale.Floor.getString(context)), + ), + onFieldSubmitted: (_) => onSubmit(context), + ), + const SizedBox.square(dimension: 10), + Row( + mainAxisAlignment: MainAxisAlignment.center, children: [ - TextFormField( - controller: search.room, - decoration: InputDecoration( - contentPadding: const EdgeInsets.all(8.0), - icon: const Icon(Icons.room_outlined), - label: Text(AppLocale.Room.getString(context)), + Expanded( + child: TextButton.icon( + icon: const Icon(Icons.done_outlined), + label: Text(AppLocale.Search.getString(context)), + onPressed: () => onSubmit(context), ), - onFieldSubmitted: (_) => onSubmit(context), - validator: (value) => roomValidator(context, required: false, value: value), ), - TextFormField( - controller: search.floor, - decoration: InputDecoration( - contentPadding: const EdgeInsets.all(8.0), - icon: const Icon(Icons.apartment_outlined), - label: Text(AppLocale.Floor.getString(context)), + Expanded( + child: TextButton.icon( + icon: const Icon(Icons.clear_outlined), + label: Text(AppLocale.ClearAll.getString(context)), + onPressed: () { + search.room.clear(); + search.floor.clear(); + + onSubmit(context); + }, ), - onFieldSubmitted: (_) => onSubmit(context), - ), - const SizedBox.square(dimension: 10), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Expanded( - child: TextButton.icon( - icon: const Icon(Icons.done_outlined), - label: Text(AppLocale.Search.getString(context)), - onPressed: () => onSubmit(context), - ), - ), - Expanded( - child: TextButton.icon( - icon: const Icon(Icons.clear_outlined), - label: Text(AppLocale.ClearAll.getString(context)), - onPressed: () { - search.room.clear(); - search.floor.clear(); - - onSubmit(context); - }, - ), - ), - ], ), ], ), - ), - ], + ], + ), ), - ); - - if (submitted == null) { - // Dialog dismissed. Restore field values - search.room.text = roomSearch; - search.floor.text = floorSearch; - } - }, - ), - ], + ], + ), + ); + + if (submitted == null) { + // Dialog dismissed. Restore field values + search.room.text = roomSearch; + search.floor.text = floorSearch; + } + }, ), - ), + ], ), - SliverToBoxAdapter(child: Center(child: _notification)), - SliverPadding( - padding: const EdgeInsets.all(5), - sliver: SliverToBoxAdapter( - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Container( - width: max(mediaQuery.size.width, 1000), - padding: const EdgeInsets.all(5), - child: Table(children: rows), - ), - ), + ), + ), + SliverToBoxAdapter(child: Center(child: _notification)), + SliverPadding( + padding: const EdgeInsets.all(5), + sliver: SliverToBoxAdapter( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Container( + width: max(mediaQuery.size.width, 1000), + padding: const EdgeInsets.all(5), + child: Table(children: rows), ), ), - ], - ); - } - - return SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox.square( - dimension: 50, - child: Icon(Icons.highlight_off_outlined), - ), - const SizedBox.square(dimension: 5), - Text((code == null ? AppLocale.ConnectionError : AppLocale.errorMessage(code)).getString(context)), - ], ), ), - ); + ], + ); } + + return SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox.square( + dimension: 50, + child: Icon(Icons.highlight_off_outlined), + ), + const SizedBox.square(dimension: 5), + Text((code == null ? AppLocale.ConnectionError : AppLocale.errorMessage(code)).getString(context)), + ], + ), + ), + ); }, ), ); diff --git a/app/resident_manager/lib/src/widgets/common.dart b/app/resident_manager/lib/src/widgets/common.dart index ab332d3..c707ea1 100644 --- a/app/resident_manager/lib/src/widgets/common.dart +++ b/app/resident_manager/lib/src/widgets/common.dart @@ -99,16 +99,6 @@ class _CommonScaffoldState extends State openDrawer(), - icon: const Icon(Icons.menu_outlined), - ), - ), - ], flexibleSpace: FlexibleSpaceBar( background: Image.asset( "assets/vector-background-blue.png", diff --git a/app/resident_manager/lib/src/widgets/payment.dart b/app/resident_manager/lib/src/widgets/payment.dart index c7d8120..091e10f 100644 --- a/app/resident_manager/lib/src/widgets/payment.dart +++ b/app/resident_manager/lib/src/widgets/payment.dart @@ -1,9 +1,17 @@ +import "dart:async"; +import "dart:io"; +import "dart:math"; + import "package:flutter/material.dart"; import "package:flutter_localization/flutter_localization.dart"; import "common.dart"; import "state.dart"; +import "utils.dart"; +import "../config.dart"; import "../translations.dart"; +import "../utils.dart"; +import "../models/payment_status.dart"; class PaymentPage extends StateAwareWidget { const PaymentPage({super.key, required super.state}); @@ -12,13 +20,262 @@ class PaymentPage extends StateAwareWidget { AbstractCommonState createState() => _PaymentPageState(); } +class _Pagination extends FutureHolder { + int offset = 0; + + @override + Future run() async { + return offset; + } +} + +class _QueryLoader extends FutureHolder { + final statuses = []; + + final _PaymentPageState _state; + + _QueryLoader(this._state); + + @override + Future run() async { + try { + final result = await PaymentStatus.query( + state: _state.state, + offset: DB_PAGINATION_QUERY * _state.pagination.offset, + createdFrom: epoch, + createdTo: DateTime.now().toUtc(), + ); + + final data = result.data; + if (data != null) { + statuses.clear(); + statuses.addAll(data); + } + + return result.code; + } catch (e) { + if (e is SocketException || e is TimeoutException) { + await showToastSafe(msg: _state.mounted ? AppLocale.ConnectionError.getString(_state.context) : AppLocale.ConnectionError); + return null; + } + + rethrow; + } finally { + _state.refresh(); + } + } +} + class _PaymentPageState extends AbstractCommonState with CommonStateMixin { + _Pagination? _pagination; + _Pagination get pagination => _pagination ??= _Pagination(); + + _QueryLoader? _queryLoader; + _QueryLoader get queryLoader => _queryLoader ??= _QueryLoader(this); + + void reload() { + pagination.reload(); + queryLoader.reload(); + refresh(); + } + @override CommonScaffold build(BuildContext context) { + final mediaQuery = MediaQuery.of(context); + return CommonScaffold.single( widgetState: this, title: Text(AppLocale.Payment.getString(context), style: const TextStyle(fontWeight: FontWeight.bold)), - sliver: const SliverToBoxAdapter(child: SizedBox.shrink()), + sliver: FutureBuilder( + future: queryLoader.future, + initialData: queryLoader.lastData, + builder: (context, snapshot) { + if (queryLoader.isLoading) { + return const SliverCircularProgressFullScreen(); + } + + final code = queryLoader.lastData; + if (code == 0) { + return SliverMainAxisGroup( + slivers: [ + SliverPadding( + padding: const EdgeInsets.all(5), + sliver: SliverToBoxAdapter( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + icon: const Icon(Icons.chevron_left_outlined), + onPressed: () { + if (pagination.offset > 0) { + pagination.offset--; + reload(); + } + }, + ), + FutureBuilder( + future: pagination.future, + builder: (context, _) { + final offset = pagination.offset; + return Text("${offset + 1}"); + }, + ), + IconButton( + icon: const Icon(Icons.chevron_right_outlined), + onPressed: () { + pagination.offset++; + reload(); + }, + ), + IconButton( + icon: const Icon(Icons.refresh_outlined), + onPressed: () { + pagination.offset = 0; + reload(); + }, + ), + ], + ), + ), + ), + SliverPadding( + padding: const EdgeInsets.all(5), + sliver: SliverToBoxAdapter( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Container( + width: max(mediaQuery.size.width, 1000), + padding: const EdgeInsets.all(5), + child: Table( + children: [ + TableRow( + decoration: const BoxDecoration(border: BorderDirectional(bottom: BorderSide(width: 1))), + children: [ + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text( + AppLocale.Fee.getString(context), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text( + AppLocale.Description.getString(context), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text( + AppLocale.Minimum.getString(context), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text( + AppLocale.Maximum.getString(context), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text( + AppLocale.CreationTime.getString(context), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text( + AppLocale.AmountPaid.getString(context), + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + ), + ], + ), + for (final status in queryLoader.statuses) + TableRow( + children: [ + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(status.fee.name), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(status.fee.description), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(status.lowerBound.floor().toString()), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(status.upperBound.floor().toString()), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(status.fee.createdAt.toString()), + ), + ), + TableCell( + child: Padding( + padding: const EdgeInsets.all(5), + child: Text(status.payment?.amount.toString() ?? "---"), + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ], + ); + } + + return SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox.square( + dimension: 50, + child: Icon(Icons.highlight_off_outlined), + ), + const SizedBox.square(dimension: 5), + Text((code == null ? AppLocale.ConnectionError : AppLocale.errorMessage(code)).getString(context)), + ], + ), + ), + ); + }, + ), ); } } diff --git a/app/resident_manager/lib/src/widgets/utils.dart b/app/resident_manager/lib/src/widgets/utils.dart index 5331021..cae4966 100644 --- a/app/resident_manager/lib/src/widgets/utils.dart +++ b/app/resident_manager/lib/src/widgets/utils.dart @@ -1,4 +1,7 @@ import "package:flutter/material.dart"; +import "package:flutter_localization/flutter_localization.dart"; + +import "../translations.dart"; class HoverContainer extends StatefulWidget { final Color onHover; @@ -27,3 +30,27 @@ class _HoverContainerState extends State { ); } } + +class SliverCircularProgressFullScreen extends StatelessWidget { + const SliverCircularProgressFullScreen({super.key}); + + @override + Widget build(BuildContext context) { + return SliverFillRemaining( + hasScrollBody: false, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox.square( + dimension: 50, + child: CircularProgressIndicator(), + ), + const SizedBox.square(dimension: 5), + Text(AppLocale.Loading.getString(context)), + ], + ), + ), + ); + } +} diff --git a/server/v1/models/payment_status.py b/server/v1/models/payment_status.py index dd92ed2..7a322b7 100644 --- a/server/v1/models/payment_status.py +++ b/server/v1/models/payment_status.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import datetime +from datetime import datetime, timezone from typing import Annotated, List, Optional import pydantic @@ -65,8 +65,8 @@ async def query( created_from: datetime, created_to: datetime, ) -> List[PaymentStatus]: - created_from = max(created_from, EPOCH) - created_to = max(created_to, EPOCH) + created_from = max(created_from.astimezone(timezone.utc), EPOCH) + created_to = max(created_to.astimezone(timezone.utc), EPOCH) async with Database.instance.pool.acquire() as connection: async with connection.cursor() as cursor: diff --git a/server/v1/routes/residents/pay.py b/server/v1/routes/residents/pay.py index bfe06e0..aee7a1c 100644 --- a/server/v1/routes/residents/pay.py +++ b/server/v1/routes/residents/pay.py @@ -12,8 +12,8 @@ from ...app import api_v1 from ...models import PaymentStatus -from ....config import EPOCH, VNPAY_SECRET_KEY, VNPAY_TMN_CODE -from ....utils import since_epoch +from ....config import VNPAY_SECRET_KEY, VNPAY_TMN_CODE +from ....utils import since_epoch, snowflake_time __all__ = ("residents_pay",) @@ -29,7 +29,7 @@ def _format_time(time: datetime) -> str: name="Fee payment", description="Perform a payment for a fee", tags=["resident"], - include_in_schema=False, + # include_in_schema=False, ) async def residents_pay( request: Request, @@ -37,8 +37,8 @@ async def residents_pay( fee_id: int, amount: float, ) -> RedirectResponse: - now = datetime.now(timezone(timedelta(hours=7))) - all_status = await PaymentStatus.query(room, created_from=EPOCH, created_to=now) + date = snowflake_time(fee_id) + all_status = await PaymentStatus.query(room, created_from=date, created_to=date) for st in all_status: if st.payment is None and st.fee.id == fee_id: break @@ -50,6 +50,7 @@ async def residents_pay( raise HTTPException(status.HTTP_400_BAD_REQUEST) # Construct VNPay URL + now = datetime.now(timezone(timedelta(hours=7))) expire = now + timedelta(hours=1) unique_suffix = int(1000 * since_epoch(now).total_seconds())