update ui ux and fix endpoint

This commit is contained in:
kyawkhantwin 2026-03-30 16:10:06 +06:30
parent 8cc8d14ad6
commit 50b041e9ba
33 changed files with 3017 additions and 666 deletions

View File

@ -1,7 +1,7 @@
class AppConfig { class AppConfig {
const AppConfig._(); const AppConfig._();
static const String apiBaseUrl = 'https://api-tms-uat.kbzbank.com:8443'; // static const String apiBaseUrl = 'https://api-tms-uat.kbzbank.com:8443';
// static const String apiBaseUrl = 'https://receipt-nest.utsmyanmar.com'; static const String apiBaseUrl = 'https://receipt-nest.utsmyanmar.com';
static const String apiSecret = 'y812J21lhha11OS'; static const String apiSecret = 'y812J21lhha11OS';
} }

View File

@ -0,0 +1,24 @@
import 'package:http/http.dart' as http;
class ApiKeyHttpClient extends http.BaseClient {
ApiKeyHttpClient({required http.Client innerClient, required this.apiSecret})
: _innerClient = innerClient;
final http.Client _innerClient;
final String apiSecret;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) {
if (apiSecret.trim().isNotEmpty &&
!request.headers.keys.any((k) => k.toLowerCase() == 'x-api-key')) {
request.headers['x-api-key'] = apiSecret.trim();
}
return _innerClient.send(request);
}
@override
void close() {
_innerClient.close();
super.close();
}
}

View File

@ -5,7 +5,7 @@ import 'package:http/http.dart' as http;
class LoggingHttpClient extends http.BaseClient { class LoggingHttpClient extends http.BaseClient {
LoggingHttpClient([http.Client? innerClient]) LoggingHttpClient([http.Client? innerClient])
: _innerClient = innerClient ?? http.Client(); : _innerClient = innerClient ?? http.Client();
final http.Client _innerClient; final http.Client _innerClient;
@ -17,7 +17,10 @@ class LoggingHttpClient extends http.BaseClient {
try { try {
final response = await _innerClient.send(request); final response = await _innerClient.send(request);
final responseBytes = await response.stream.toBytes(); final responseBytes = await response.stream.toBytes();
final responseBody = utf8.decode(responseBytes); final responseBody = _formatBodyForLogging(
responseBytes: responseBytes,
contentType: response.headers['content-type'],
);
_logResponse(response, responseBody); _logResponse(response, responseBody);
@ -84,4 +87,32 @@ class LoggingHttpClient extends http.BaseClient {
return safeHeaders; return safeHeaders;
} }
String _formatBodyForLogging({
required List<int> responseBytes,
required String? contentType,
}) {
final normalizedType = contentType?.toLowerCase() ?? '';
final isBinary =
normalizedType.contains('application/pdf') ||
normalizedType.contains('application/octet-stream') ||
normalizedType.startsWith('image/') ||
normalizedType.startsWith('audio/') ||
normalizedType.startsWith('video/');
if (isBinary) {
return '<binary ${responseBytes.length} bytes>';
}
final decoded = utf8.decode(responseBytes, allowMalformed: true).trim();
if (decoded.isEmpty) {
return '';
}
const maxChars = 4000;
if (decoded.length <= maxChars) {
return decoded;
}
return '${decoded.substring(0, maxChars)}';
}
} }

View File

@ -20,7 +20,7 @@ class ApiAuthRepository implements AuthRepository {
required String username, required String username,
required String password, required String password,
}) async { }) async {
final uri = Uri.parse('$baseUrl/receipt/auth/login'); final uri = Uri.parse('$baseUrl/auth/login');
final requestBody = jsonEncode({ final requestBody = jsonEncode({
'username': username.trim().toLowerCase(), 'username': username.trim().toLowerCase(),
'password': password, 'password': password,
@ -51,7 +51,7 @@ class ApiAuthRepository implements AuthRepository {
required String refreshToken, required String refreshToken,
required String role, required String role,
}) async { }) async {
final uri = Uri.parse('$baseUrl/receipt/auth/refresh-token'); final uri = Uri.parse('$baseUrl/auth/refresh-token');
final requestBody = jsonEncode({ final requestBody = jsonEncode({
'refreshToken': refreshToken, 'refreshToken': refreshToken,
}); });

View File

@ -17,8 +17,20 @@ class ApiMerchantRepository implements MerchantRepository {
final http.Client _client; final http.Client _client;
@override @override
Future<List<Merchant>> getMerchants({required String token}) async { Future<List<Merchant>> getMerchants({
final uri = Uri.parse('$baseUrl/receipt/merchant'); required String token,
int page = 1,
int limit = 10,
String searchTerm = '',
}) async {
final queryParameters = <String, String>{
'page': page.toString(),
'limit': limit.toString(),
if (searchTerm.trim().isNotEmpty) 'searchTerm': searchTerm.trim(),
};
final uri = Uri.parse(
'$baseUrl/merchant',
).replace(queryParameters: queryParameters);
final response = await _client.get( final response = await _client.get(
uri, uri,
headers: { headers: {
@ -40,7 +52,7 @@ class ApiMerchantRepository implements MerchantRepository {
required String token, required String token,
required String merchantId, required String merchantId,
}) async { }) async {
final uri = Uri.parse('$baseUrl/receipt/terminal/list/$merchantId'); final uri = Uri.parse('$baseUrl/terminal/list/$merchantId');
final response = await _client.get( final response = await _client.get(
uri, uri,
headers: { headers: {
@ -172,10 +184,10 @@ class ApiMerchantRepository implements MerchantRepository {
mids: _pickStringList(item, const ['mid', 'mids']), mids: _pickStringList(item, const ['mid', 'mids']),
merchantId: _pickString(item, const ['merchantId', 'merchant_id']), merchantId: _pickString(item, const ['merchantId', 'merchant_id']),
status: _pickString(item, const ['status']), status: _pickString(item, const ['status']),
totalTransactions: _pickInt( totalTransactions: _pickInt(item, const [
item, 'totalTransactions',
const ['totalTransactions', 'total_transactions'], 'total_transactions',
), ]),
totalAmount: _pickNum(item, const ['totalAmount', 'total_amount']), totalAmount: _pickNum(item, const ['totalAmount', 'total_amount']),
createdAt: _pickString(item, const ['createdAt', 'created_at']), createdAt: _pickString(item, const ['createdAt', 'created_at']),
updatedAt: _pickString(item, const ['updatedAt', 'updated_at']), updatedAt: _pickString(item, const ['updatedAt', 'updated_at']),
@ -247,7 +259,8 @@ class ApiMerchantRepository implements MerchantRepository {
String _extractErrorMessage(String body) { String _extractErrorMessage(String body) {
final decoded = _tryDecode(body); final decoded = _tryDecode(body);
if (decoded is Map<String, dynamic>) { if (decoded is Map<String, dynamic>) {
final message = decoded['message'] ?? decoded['error'] ?? decoded['detail']; final message =
decoded['message'] ?? decoded['error'] ?? decoded['detail'];
if (message != null) { if (message != null) {
return message.toString(); return message.toString();
} }

View File

@ -0,0 +1,88 @@
import 'dart:convert';
import 'dart:io';
import 'package:e_receipt_mobile/domain/entities/receipt_content.dart';
import 'package:e_receipt_mobile/domain/repositories/receipt_repository.dart';
import 'package:http/http.dart' as http;
class ApiReceiptRepository implements ReceiptRepository {
ApiReceiptRepository({
required this.baseUrl,
required this.apiSecret,
required http.Client client,
}) : _client = client;
final String baseUrl;
final String apiSecret;
final http.Client _client;
@override
Future<ReceiptContent> getTransactionReceipt({
required String token,
required String transactionId,
required String copyFor,
}) async {
final uri = _normalizeLocalhostForAndroid(
Uri.parse('$baseUrl/transaction/pdf-html').replace(
queryParameters: <String, String>{
'transactionId': transactionId,
'copyFor': copyFor,
},
),
);
final request = http.Request('GET', uri);
request.headers.addAll(<String, String>{
'Accept': 'application/pdf,text/html',
'Content-Type': 'application/json',
'x-api-key': apiSecret,
if (token.trim().isNotEmpty) 'Authorization': 'Bearer ${token.trim()}',
});
final streamed = await _client.send(request);
final bytes = await streamed.stream.toBytes();
final contentType = streamed.headers['content-type']?.trim() ?? '';
if (streamed.statusCode < 200 || streamed.statusCode >= 300) {
final body = const Utf8Decoder(
allowMalformed: true,
).convert(bytes).trim();
throw Exception(
body.isNotEmpty && body.length <= 500
? body
: 'Failed to load receipt (${streamed.statusCode}) $contentType',
);
}
if (_looksLikePdf(bytes: bytes, contentType: contentType)) {
return ReceiptPdfContent(bytes);
}
final html = const Utf8Decoder(allowMalformed: true).convert(bytes);
return ReceiptHtmlContent(html: html, contentType: contentType);
}
Uri _normalizeLocalhostForAndroid(Uri uri) {
if (!Platform.isAndroid) {
return uri;
}
if (uri.host != 'localhost') {
return uri;
}
return uri.replace(host: '10.0.2.2');
}
bool _looksLikePdf({required List<int> bytes, required String contentType}) {
final type = contentType.toLowerCase();
if (type.contains('application/pdf')) {
return true;
}
if (bytes.length < 4) {
return false;
}
return bytes[0] == 0x25 && // %
bytes[1] == 0x50 && // P
bytes[2] == 0x44 && // D
bytes[3] == 0x46; // F
}
}

View File

@ -21,7 +21,7 @@ class ApiTransactionRepository implements TransactionRepository {
required String serial, required String serial,
required String range, required String range,
}) async { }) async {
final uri = Uri.parse('$baseUrl/receipt/transaction').replace( final uri = Uri.parse('$baseUrl/transaction').replace(
queryParameters: { queryParameters: {
'serial': serial, 'serial': serial,
'range': range, 'range': range,

View File

@ -0,0 +1,18 @@
import 'dart:typed_data';
sealed class ReceiptContent {
const ReceiptContent();
}
class ReceiptPdfContent extends ReceiptContent {
const ReceiptPdfContent(this.bytes);
final Uint8List bytes;
}
class ReceiptHtmlContent extends ReceiptContent {
const ReceiptHtmlContent({required this.html, required this.contentType});
final String html;
final String contentType;
}

View File

@ -2,7 +2,12 @@ import 'package:e_receipt_mobile/domain/entities/merchant.dart';
import 'package:e_receipt_mobile/domain/entities/terminal.dart'; import 'package:e_receipt_mobile/domain/entities/terminal.dart';
abstract class MerchantRepository { abstract class MerchantRepository {
Future<List<Merchant>> getMerchants({required String token}); Future<List<Merchant>> getMerchants({
required String token,
int page = 1,
int limit = 10,
String searchTerm = '',
});
Future<List<Terminal>> getTerminalsByMerchantId({ Future<List<Terminal>> getTerminalsByMerchantId({
required String token, required String token,

View File

@ -0,0 +1,9 @@
import 'package:e_receipt_mobile/domain/entities/receipt_content.dart';
abstract class ReceiptRepository {
Future<ReceiptContent> getTransactionReceipt({
required String token,
required String transactionId,
required String copyFor,
});
}

View File

@ -1,4 +1,6 @@
import 'package:e_receipt_mobile/core/theme/app_colors.dart'; import 'package:e_receipt_mobile/core/theme/app_colors.dart';
import 'package:e_receipt_mobile/presentation/auth/session_controller.dart';
import 'package:e_receipt_mobile/presentation/home/home_screen.dart';
import 'package:e_receipt_mobile/presentation/login/login_page.dart'; import 'package:e_receipt_mobile/presentation/login/login_page.dart';
import 'package:e_receipt_mobile/presentation/settings/theme_mode_provider.dart'; import 'package:e_receipt_mobile/presentation/settings/theme_mode_provider.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -65,7 +67,20 @@ class EReceiptApp extends ConsumerWidget {
), ),
useMaterial3: true, useMaterial3: true,
), ),
home: const LoginPage(), home: const AppRoot(),
); );
} }
} }
class AppRoot extends ConsumerWidget {
const AppRoot({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(sessionControllerProvider);
if (user == null) {
return const LoginPage();
}
return HomeScreen(user: user);
}
}

View File

@ -0,0 +1,7 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
final merchantPageSizeProvider = Provider<int>((ref) => 10);
final merchantSearchQueryProvider = StateProvider.autoDispose<String>((ref) {
return '';
});

View File

@ -1,8 +1,10 @@
import 'package:e_receipt_mobile/core/theme/app_colors.dart';
import 'package:e_receipt_mobile/domain/entities/login_user.dart'; import 'package:e_receipt_mobile/domain/entities/login_user.dart';
import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; import 'package:e_receipt_mobile/presentation/auth/session_controller.dart';
import 'package:e_receipt_mobile/presentation/home/home_view_model.dart'; import 'package:e_receipt_mobile/presentation/home/home_pagination_providers.dart';
import 'package:e_receipt_mobile/presentation/login/login_page.dart'; import 'package:e_receipt_mobile/presentation/home/merchant_paging_view_model.dart';
import 'package:e_receipt_mobile/presentation/home/widgets/home_drawer.dart';
import 'package:e_receipt_mobile/presentation/home/widgets/merchant_header.dart';
import 'package:e_receipt_mobile/presentation/home/widgets/merchant_list_view.dart';
import 'package:e_receipt_mobile/presentation/settings/settings_screen.dart'; import 'package:e_receipt_mobile/presentation/settings/settings_screen.dart';
import 'package:e_receipt_mobile/presentation/terminal/terminal_selection_screen.dart'; import 'package:e_receipt_mobile/presentation/terminal/terminal_selection_screen.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@ -15,218 +17,190 @@ class HomeScreen extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final merchantsAsync = ref.watch(merchantListProvider); final pagingState = ref.watch(merchantPagingViewModelProvider);
final pagingViewModel = ref.read(merchantPagingViewModelProvider.notifier);
final colorScheme = Theme.of(context).colorScheme;
final query = ref.watch(merchantSearchQueryProvider);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text("Merchants"), title: const Text('Merchants'),
centerTitle: true, actions: [
), IconButton(
drawer: Drawer( tooltip: 'Refresh',
backgroundColor: Colors.white, onPressed: pagingState.isLoading
? null
child: Column( : () => pagingViewModel.refresh(),
children: [ icon: const Icon(Icons.refresh),
UserAccountsDrawerHeader(
decoration: const BoxDecoration(color: AppColors.primary),
accountName: Text(
user.username,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
accountEmail: Text(
'Role: ${user.role}',
style: const TextStyle(color: Colors.white70),
),
currentAccountPicture: CircleAvatar(
backgroundColor: Colors.white,
child: Text(
(user.username.isNotEmpty ? user.username[0] : 'U')
.toUpperCase(),
style: const TextStyle(color: AppColors.primary),
),
),
),
ListTile(
leading: const Icon(Icons.person_outline, color: Colors.black87),
title: const Text(
'Profile',
style: TextStyle(
color: Colors.black87,
fontWeight: FontWeight.w500,
),
),
onTap: () {
Navigator.of(context).pop();
_showProfile(context);
},
),
// ListTile(
// leading: const Icon(Icons.storefront_outlined),
// title: const Text('Merchants'),
// onTap: () => Navigator.of(context).pop(),
// ),
ListTile(
leading: const Icon(Icons.analytics_outlined, color: Colors.black87),
title: const Text(
'Reports',
style: TextStyle(
color: Colors.black87,
fontWeight: FontWeight.w500,
),
),
onTap: () {
Navigator.of(context).pop();
_showComingSoon(context, 'Reports');
},
),
ListTile(
leading: const Icon(Icons.settings_outlined, color: Colors.black87),
title: const Text(
'Settings',
style: TextStyle(
color: Colors.black87,
fontWeight: FontWeight.w500,
),
),
onTap: () {
Navigator.of(context).pop();
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const SettingsScreen(),
),
);
},
),
ListTile(
leading: const Icon(Icons.help_outline, color: Colors.black87),
title: const Text(
'Help',
style: TextStyle(
color: Colors.black87,
fontWeight: FontWeight.w500,
),
),
onTap: () {
Navigator.of(context).pop();
_showComingSoon(context, 'Help');
},
),
const Spacer(),
const Divider(height: 1, color: Colors.black12),
ListTile(
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
minTileHeight: 64,
leading: const Icon(Icons.logout, color: Colors.black87),
title: const Text(
'Logout',
style: TextStyle(
color: Colors.black87,
fontWeight: FontWeight.w600,
),
),
onTap: () async {
final shouldLogout = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Logout'),
content: const Text('Are you sure want to logout?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Logout'),
),
],
);
},
);
if (shouldLogout != true || !context.mounted) {
return;
}
ref.read(sessionControllerProvider.notifier).clearUser();
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute<void>(
builder: (_) => const LoginPage(),
),
(route) => false,
);
},
),
],
), ),
],
), ),
body: Padding( drawer: HomeDrawer(
padding: const EdgeInsets.all(16), user: user,
child: merchantsAsync.when( onProfile: () {
loading: () => const Center(child: CircularProgressIndicator()), Navigator.of(context).pop();
error: (error, _) => Center( _showProfile(context, user);
child: Text('Failed to load merchants: $error'), },
), onReports: () {
data: (merchants) { Navigator.of(context).pop();
return Column( _showComingSoon(context, 'Reports');
crossAxisAlignment: CrossAxisAlignment.start, },
onSettings: () {
Navigator.of(context).pop();
Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => const SettingsScreen()),
);
},
onHelp: () {
Navigator.of(context).pop();
_showComingSoon(context, 'Help');
},
onLogout: () async {
final shouldLogout = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Logout'),
content: const Text('Are you sure you want to logout?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('Logout'),
),
],
);
},
);
if (shouldLogout != true || !context.mounted) {
return;
}
Navigator.of(context).pop(); // close drawer
ref.read(sessionControllerProvider.notifier).clearUser();
},
),
body: pagingState.isLoading && pagingState.items.isEmpty
? const Center(child: CircularProgressIndicator())
: pagingState.errorMessage != null && pagingState.items.isEmpty
? Center(
child: Padding(
padding: EdgeInsets.fromLTRB(
24,
24,
24,
MediaQuery.viewPaddingOf(context).bottom + 24,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.wifi_off_outlined,
size: 56,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 12),
Text(
'Failed to load merchants',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
Text(
pagingState.errorMessage!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 14),
FilledButton.tonal(
onPressed: pagingViewModel.refresh,
child: const Text('Try again'),
),
],
),
),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( MerchantHeader(
'Total (${merchants.length})', loadedCount: pagingState.items.length,
style: Theme.of(context).textTheme.titleLarge, query: query,
onQueryChanged: (value) {
ref.read(merchantSearchQueryProvider.notifier).state =
value;
pagingViewModel.setSearchTerm(value);
},
onClear: () {
ref.read(merchantSearchQueryProvider.notifier).state = '';
pagingViewModel.setSearchTerm('');
},
), ),
const SizedBox(height: 8),
Expanded( Expanded(
child: RefreshIndicator( child: MerchantListView(
onRefresh: () => ref.refresh(merchantListProvider.future), merchants: pagingState.items,
child: merchants.isEmpty hasMore: pagingState.hasMore,
? ListView( isLoadingMore: pagingState.isLoadingMore,
physics: const AlwaysScrollableScrollPhysics(), onRefresh: pagingViewModel.refresh,
children: const [ onMerchantTap: (merchant) {
SizedBox(height: 120), final id = merchant.id;
Center(child: Text('No merchants found')), if (id == null) {
], return;
) }
: ListView.separated( _openTerminalSelection(context, id, merchant.name ?? '-');
physics: const AlwaysScrollableScrollPhysics(), },
itemCount: merchants.length, onLoadMore: pagingViewModel.loadMore,
separatorBuilder: (_, __) => onEndReached: pagingViewModel.loadMore,
const SizedBox(height: 10),
itemBuilder: (context, index) {
final merchant = merchants[index];
return Card(
child: ListTile(
onTap: merchant.id == null
? null
: () => _openTerminalSelection(
context,
merchant.id!,
merchant.name ?? '-',
),
leading: const Icon(Icons.storefront_outlined),
trailing: const Icon(Icons.chevron_right),
title: Text(
'${index + 1}. ${merchant.name ?? '-'}',
),
subtitle: Text(merchant.address ?? '-'),
),
);
},
),
), ),
), ),
if (pagingState.errorMessage != null &&
pagingState.items.isNotEmpty)
Padding(
padding: EdgeInsets.fromLTRB(
16,
8,
16,
MediaQuery.viewPaddingOf(context).bottom + 8,
),
child: Material(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Icon(
Icons.error_outline,
color: colorScheme.onErrorContainer,
),
const SizedBox(width: 10),
Expanded(
child: Text(
pagingState.errorMessage!,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: colorScheme.onErrorContainer,
),
),
),
const SizedBox(width: 8),
TextButton(
onPressed: pagingViewModel.loadMore,
child: const Text('Retry'),
),
],
),
),
),
),
], ],
); ),
},
),
),
); );
} }
@ -236,7 +210,7 @@ class HomeScreen extends ConsumerWidget {
).showSnackBar(SnackBar(content: Text('$feature is coming soon'))); ).showSnackBar(SnackBar(content: Text('$feature is coming soon')));
} }
void _showProfile(BuildContext context) { void _showProfile(BuildContext context, LoginUser user) {
showDialog<void>( showDialog<void>(
context: context, context: context,
builder: (context) { builder: (context) {

View File

@ -1,6 +1,5 @@
import 'package:e_receipt_mobile/core/config/app_config.dart'; import 'package:e_receipt_mobile/core/config/app_config.dart';
import 'package:e_receipt_mobile/data/repositories/api_merchant_repository.dart'; import 'package:e_receipt_mobile/data/repositories/api_merchant_repository.dart';
import 'package:e_receipt_mobile/domain/entities/merchant.dart';
import 'package:e_receipt_mobile/domain/entities/terminal.dart'; import 'package:e_receipt_mobile/domain/entities/terminal.dart';
import 'package:e_receipt_mobile/domain/repositories/merchant_repository.dart'; import 'package:e_receipt_mobile/domain/repositories/merchant_repository.dart';
import 'package:e_receipt_mobile/presentation/auth/session_controller.dart'; import 'package:e_receipt_mobile/presentation/auth/session_controller.dart';
@ -15,7 +14,10 @@ final merchantRepositoryProvider = Provider<MerchantRepository>((ref) {
); );
}); });
final merchantListProvider = FutureProvider<List<Merchant>>((ref) async { final terminalListProvider = FutureProvider.family<List<Terminal>, String>((
ref,
merchantId,
) async {
final sessionUser = ref.watch(sessionControllerProvider); final sessionUser = ref.watch(sessionControllerProvider);
if (sessionUser == null) { if (sessionUser == null) {
throw Exception('No active session'); throw Exception('No active session');
@ -23,20 +25,8 @@ final merchantListProvider = FutureProvider<List<Merchant>>((ref) async {
return ref return ref
.watch(merchantRepositoryProvider) .watch(merchantRepositoryProvider)
.getMerchants(token: sessionUser.token); .getTerminalsByMerchantId(
token: sessionUser.token,
merchantId: merchantId,
);
}); });
final terminalListProvider =
FutureProvider.family<List<Terminal>, String>((ref, merchantId) async {
final sessionUser = ref.watch(sessionControllerProvider);
if (sessionUser == null) {
throw Exception('No active session');
}
return ref
.watch(merchantRepositoryProvider)
.getTerminalsByMerchantId(
token: sessionUser.token,
merchantId: merchantId,
);
});

View File

@ -0,0 +1,49 @@
import 'package:e_receipt_mobile/domain/entities/merchant.dart';
import 'package:flutter/foundation.dart';
@immutable
class MerchantPagingState {
const MerchantPagingState({
this.items = const <Merchant>[],
this.page = 1,
this.limit = 10,
this.searchTerm = '',
this.isLoading = false,
this.isLoadingMore = false,
this.hasMore = true,
this.errorMessage,
});
final List<Merchant> items;
final int page;
final int limit;
final String searchTerm;
final bool isLoading;
final bool isLoadingMore;
final bool hasMore;
final String? errorMessage;
MerchantPagingState copyWith({
List<Merchant>? items,
int? page,
int? limit,
String? searchTerm,
bool? isLoading,
bool? isLoadingMore,
bool? hasMore,
String? errorMessage,
bool clearError = false,
}) {
return MerchantPagingState(
items: items ?? this.items,
page: page ?? this.page,
limit: limit ?? this.limit,
searchTerm: searchTerm ?? this.searchTerm,
isLoading: isLoading ?? this.isLoading,
isLoadingMore: isLoadingMore ?? this.isLoadingMore,
hasMore: hasMore ?? this.hasMore,
errorMessage: clearError ? null : errorMessage ?? this.errorMessage,
);
}
}

View File

@ -0,0 +1,130 @@
import 'dart:async';
import 'package:e_receipt_mobile/domain/repositories/merchant_repository.dart';
import 'package:e_receipt_mobile/presentation/auth/session_controller.dart';
import 'package:e_receipt_mobile/presentation/home/home_pagination_providers.dart';
import 'package:e_receipt_mobile/presentation/home/home_view_model.dart';
import 'package:e_receipt_mobile/presentation/home/merchant_paging_state.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final merchantPagingViewModelProvider =
StateNotifierProvider.autoDispose<
MerchantPagingViewModel,
MerchantPagingState
>((ref) {
final sessionUser = ref.watch(sessionControllerProvider);
if (sessionUser == null) {
throw Exception('No active session');
}
final limit = ref.watch(merchantPageSizeProvider);
final viewModel = MerchantPagingViewModel(
repository: ref.watch(merchantRepositoryProvider),
token: sessionUser.token,
limit: limit,
searchTerm: '',
);
ref.onDispose(viewModel.dispose);
viewModel.loadInitial();
return viewModel;
});
class MerchantPagingViewModel extends StateNotifier<MerchantPagingState> {
MerchantPagingViewModel({
required MerchantRepository repository,
required String token,
required int limit,
required String searchTerm,
}) : _repository = repository,
_token = token,
super(MerchantPagingState(limit: limit, searchTerm: searchTerm));
final MerchantRepository _repository;
final String _token;
Timer? _searchDebounce;
void dispose() {
_searchDebounce?.cancel();
}
Future<void> loadInitial() async {
await refresh(searchTerm: state.searchTerm);
}
Future<void> refresh({String? searchTerm}) async {
final nextSearch = searchTerm ?? state.searchTerm;
state = state.copyWith(
isLoading: true,
isLoadingMore: false,
page: 1,
items: const [],
hasMore: true,
searchTerm: nextSearch,
clearError: true,
);
try {
final results = await _repository.getMerchants(
token: _token,
page: 1,
limit: state.limit,
searchTerm: nextSearch,
);
state = state.copyWith(
isLoading: false,
items: results,
page: 1,
hasMore: results.length >= state.limit,
);
} catch (error) {
state = state.copyWith(
isLoading: false,
errorMessage: error.toString().replaceFirst('Exception: ', ''),
);
}
}
void setSearchTerm(String value) {
final next = value;
if (next == state.searchTerm) {
return;
}
_searchDebounce?.cancel();
_searchDebounce = Timer(const Duration(milliseconds: 350), () {
refresh(searchTerm: next);
});
}
Future<void> loadMore() async {
if (state.isLoading || state.isLoadingMore || !state.hasMore) {
return;
}
state = state.copyWith(isLoadingMore: true, clearError: true);
final nextPage = state.page + 1;
try {
final results = await _repository.getMerchants(
token: _token,
page: nextPage,
limit: state.limit,
searchTerm: state.searchTerm,
);
final merged = [...state.items, ...results];
state = state.copyWith(
isLoadingMore: false,
items: merged,
page: nextPage,
hasMore: results.length >= state.limit,
);
} catch (error) {
state = state.copyWith(
isLoadingMore: false,
errorMessage: error.toString().replaceFirst('Exception: ', ''),
);
}
}
}

View File

@ -0,0 +1,173 @@
import 'package:e_receipt_mobile/core/theme/app_colors.dart';
import 'package:e_receipt_mobile/domain/entities/login_user.dart';
import 'package:flutter/material.dart';
class HomeDrawer extends StatelessWidget {
const HomeDrawer({
required this.user,
required this.onProfile,
required this.onReports,
required this.onSettings,
required this.onHelp,
required this.onLogout,
super.key,
});
final LoginUser user;
final VoidCallback onProfile;
final VoidCallback onReports;
final VoidCallback onSettings;
final VoidCallback onHelp;
final VoidCallback onLogout;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Drawer(
child: SafeArea(
child: Column(
children: [
_DrawerHeader(user: user),
Expanded(
child: ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
_DrawerItem(
icon: Icons.person_outline,
label: 'Profile',
onTap: onProfile,
),
_DrawerItem(
icon: Icons.analytics_outlined,
label: 'Reports',
onTap: onReports,
),
_DrawerItem(
icon: Icons.settings_outlined,
label: 'Settings',
onTap: onSettings,
),
_DrawerItem(
icon: Icons.help_outline,
label: 'Help',
onTap: onHelp,
),
],
),
),
Divider(
height: 1,
thickness: 1,
color: colorScheme.outlineVariant.withOpacity(0.6),
),
Padding(
padding: const EdgeInsets.fromLTRB(8, 4, 8, 8),
child: _DrawerItem(
icon: Icons.logout,
label: 'Logout',
isDestructive: true,
onTap: onLogout,
),
),
],
),
),
);
}
}
class _DrawerHeader extends StatelessWidget {
const _DrawerHeader({required this.user});
final LoginUser user;
@override
Widget build(BuildContext context) {
final initials = (user.username.isNotEmpty ? user.username[0] : 'U')
.toUpperCase();
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(16, 16, 16, 14),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.primary, Color(0xFF1E5FD6)],
),
),
child: Row(
children: [
CircleAvatar(
radius: 22,
backgroundColor: Colors.white,
child: Text(
initials,
style: const TextStyle(
color: AppColors.primary,
fontWeight: FontWeight.w700,
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
user.username,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.w700,
fontSize: 16,
),
),
const SizedBox(height: 2),
Text(
'Role: ${user.role}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.white70),
),
],
),
),
],
),
);
}
}
class _DrawerItem extends StatelessWidget {
const _DrawerItem({
required this.icon,
required this.label,
required this.onTap,
this.isDestructive = false,
});
final IconData icon;
final String label;
final VoidCallback onTap;
final bool isDestructive;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final foreground = isDestructive
? colorScheme.error
: colorScheme.onSurface;
return ListTile(
leading: Icon(icon, color: foreground),
title: Text(
label,
style: TextStyle(fontWeight: FontWeight.w600, color: foreground),
),
onTap: onTap,
);
}
}

View File

@ -0,0 +1,82 @@
import 'package:flutter/material.dart';
class MerchantHeader extends StatelessWidget {
const MerchantHeader({
required this.loadedCount,
required this.query,
required this.onQueryChanged,
required this.onClear,
super.key,
});
final int loadedCount;
final String query;
final ValueChanged<String> onQueryChanged;
final VoidCallback onClear;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Merchants',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
'Select a merchant to continue',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: 12),
Chip(
label: Text('$loadedCount'),
side: BorderSide(color: colorScheme.outlineVariant),
backgroundColor: colorScheme.surfaceContainerHigh,
),
],
),
const SizedBox(height: 12),
SearchBar(
leading: const Icon(Icons.search),
hintText: 'Search merchants',
padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: 14),
),
backgroundColor: WidgetStatePropertyAll(
colorScheme.surfaceContainerHigh,
),
elevation: const WidgetStatePropertyAll(0),
constraints: const BoxConstraints(minHeight: 52),
trailing: [
if (query.isNotEmpty)
IconButton(
tooltip: 'Clear',
onPressed: onClear,
icon: const Icon(Icons.close),
),
],
onChanged: onQueryChanged,
),
],
),
);
}
}

View File

@ -0,0 +1,233 @@
import 'package:e_receipt_mobile/domain/entities/merchant.dart';
import 'package:flutter/material.dart';
class MerchantListView extends StatelessWidget {
const MerchantListView({
required this.merchants,
required this.onRefresh,
required this.onMerchantTap,
required this.hasMore,
required this.isLoadingMore,
this.onLoadMore,
this.onEndReached,
this.endReachedThreshold = 240,
super.key,
});
final List<Merchant> merchants;
final Future<void> Function() onRefresh;
final void Function(Merchant merchant) onMerchantTap;
final bool hasMore;
final bool isLoadingMore;
final VoidCallback? onLoadMore;
final VoidCallback? onEndReached;
final double endReachedThreshold;
@override
Widget build(BuildContext context) {
final bottomInset = MediaQuery.viewPaddingOf(context).bottom;
final colorScheme = Theme.of(context).colorScheme;
final list = merchants.isEmpty
? ListView(
padding: EdgeInsets.fromLTRB(16, 96, 16, bottomInset + 24),
physics: const AlwaysScrollableScrollPhysics(),
children: [
Icon(
Icons.storefront_outlined,
size: 56,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
const SizedBox(height: 12),
Text(
'No merchants found',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 6),
Text(
'Try a different search term or pull down to refresh.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
)
: ListView.separated(
padding: EdgeInsets.fromLTRB(16, 8, 16, bottomInset + 16),
physics: const AlwaysScrollableScrollPhysics(),
itemCount: merchants.length + (hasMore ? 1 : 0),
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, index) {
if (index >= merchants.length) {
return _PaginationFooter(
isLoadingMore: isLoadingMore,
onLoadMore: onLoadMore,
);
}
final merchant = merchants[index];
final name = merchant.name?.trim().isEmpty ?? true
? '-'
: merchant.name!.trim();
final address = (merchant.address?.trim().isEmpty ?? true)
? 'No address'
: merchant.address!.trim();
final enabled = merchant.id != null;
return Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
side: BorderSide(
color: colorScheme.outlineVariant.withOpacity(0.55),
),
),
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: enabled ? () => onMerchantTap(merchant) : null,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
child: Row(
children: [
Container(
height: 44,
width: 44,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
color: enabled
? colorScheme.primary.withOpacity(0.14)
: colorScheme.surfaceContainerHigh,
),
child: Icon(
Icons.storefront_outlined,
color: enabled
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(
fontWeight: FontWeight.w700,
color: enabled
? null
: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 2),
Text(
address,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: enabled
? colorScheme.onSurfaceVariant
: colorScheme.onSurfaceVariant
.withOpacity(0.8),
),
),
],
),
),
const SizedBox(width: 8),
Icon(
Icons.chevron_right,
color: enabled
? colorScheme.onSurfaceVariant
: colorScheme.onSurfaceVariant.withOpacity(0.4),
),
],
),
),
),
);
},
);
final wrapped = onEndReached == null || merchants.isEmpty
? list
: NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (!hasMore || isLoadingMore) {
return false;
}
if (notification.metrics.axis != Axis.vertical) {
return false;
}
if (notification.metrics.maxScrollExtent <= 0) {
return false;
}
if (notification.metrics.pixels >=
notification.metrics.maxScrollExtent - endReachedThreshold) {
onEndReached?.call();
}
return false;
},
child: list,
);
return RefreshIndicator.adaptive(onRefresh: onRefresh, child: wrapped);
}
}
class _PaginationFooter extends StatelessWidget {
const _PaginationFooter({
required this.isLoadingMore,
required this.onLoadMore,
});
final bool isLoadingMore;
final VoidCallback? onLoadMore;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
Expanded(
child: Text(
isLoadingMore ? 'Loading more...' : 'More results available',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
if (isLoadingMore)
const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
FilledButton.tonal(
onPressed: onLoadMore,
child: const Text('Load more'),
),
],
),
),
);
}
}

View File

@ -0,0 +1,40 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final loginFormKeyProvider = Provider.autoDispose<GlobalKey<FormState>>((ref) {
return GlobalKey<FormState>();
});
final loginUsernameControllerProvider =
Provider.autoDispose<TextEditingController>((ref) {
final controller = TextEditingController();
ref.onDispose(controller.dispose);
return controller;
});
final loginPasswordControllerProvider =
Provider.autoDispose<TextEditingController>((ref) {
final controller = TextEditingController();
ref.onDispose(controller.dispose);
return controller;
});
final loginUsernameFocusNodeProvider = Provider.autoDispose<FocusNode>((ref) {
final focusNode = FocusNode();
ref.onDispose(focusNode.dispose);
return focusNode;
});
final loginPasswordFocusNodeProvider = Provider.autoDispose<FocusNode>((ref) {
final focusNode = FocusNode();
ref.onDispose(focusNode.dispose);
return focusNode;
});
final loginObscurePasswordProvider = StateProvider.autoDispose<bool>(
(ref) => true,
);
final loginAttemptedSubmitProvider = StateProvider.autoDispose<bool>(
(ref) => false,
);

View File

@ -1,142 +1,288 @@
import 'package:e_receipt_mobile/presentation/components/rounded_input.dart'; import 'package:e_receipt_mobile/presentation/login/login_form_providers.dart';
import 'package:e_receipt_mobile/presentation/home/home_screen.dart'; import 'package:e_receipt_mobile/presentation/login/login_state.dart';
import 'package:e_receipt_mobile/presentation/login/login_view_model.dart'; import 'package:e_receipt_mobile/presentation/login/login_view_model.dart';
import 'package:e_receipt_mobile/presentation/login/widgets/login_background.dart';
import 'package:e_receipt_mobile/presentation/login/widgets/login_error_banner.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
class LoginPage extends ConsumerStatefulWidget { class LoginPage extends ConsumerWidget {
const LoginPage({super.key}); const LoginPage({super.key});
@override void _submit({
ConsumerState<LoginPage> createState() => _LoginPageState(); required WidgetRef ref,
} required LoginState state,
required GlobalKey<FormState> formKey,
required TextEditingController usernameController,
required TextEditingController passwordController,
}) {
if (state.isLoading) {
return;
}
class _LoginPageState extends ConsumerState<LoginPage> { ref.read(loginViewModelProvider.notifier).clearMessages();
final _formKey = GlobalKey<FormState>(); FocusManager.instance.primaryFocus?.unfocus();
final _usernameController = TextEditingController(); ref.read(loginAttemptedSubmitProvider.notifier).state = true;
final _passwordController = TextEditingController();
@override if (!(formKey.currentState?.validate() ?? false)) {
void dispose() { return;
_usernameController.dispose(); }
_passwordController.dispose();
super.dispose(); TextInput.finishAutofillContext();
ref.read(loginViewModelProvider.notifier).login(
username: usernameController.text,
password: passwordController.text,
);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(loginViewModelProvider); final state = ref.watch(loginViewModelProvider);
final colorScheme = Theme.of(context).colorScheme;
ref.listen(loginViewModelProvider, (previous, next) { final formKey = ref.watch(loginFormKeyProvider);
if (!mounted) { final usernameController = ref.watch(loginUsernameControllerProvider);
return; final passwordController = ref.watch(loginPasswordControllerProvider);
} final usernameFocusNode = ref.watch(loginUsernameFocusNodeProvider);
final passwordFocusNode = ref.watch(loginPasswordFocusNodeProvider);
if (next.errorMessage != null && final obscurePassword = ref.watch(loginObscurePasswordProvider);
previous?.errorMessage != next.errorMessage) { final attemptedSubmit = ref.watch(loginAttemptedSubmitProvider);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(next.errorMessage!),
backgroundColor: Colors.red,
),
);
}
if (next.successMessage != null &&
next.user != null &&
previous?.successMessage != next.successMessage) {
Navigator.of(context).pushReplacement(
MaterialPageRoute<void>(
builder: (_) => HomeScreen(user: next.user!),
),
);
}
});
return Scaffold( return Scaffold(
backgroundColor: Colors.white, body: AnnotatedRegion<SystemUiOverlayStyle>(
body: Center( value: Theme.of(context).brightness == Brightness.dark
child: SingleChildScrollView( ? SystemUiOverlayStyle.light
padding: const EdgeInsets.all(32), : SystemUiOverlayStyle.dark,
child: ConstrainedBox( child: LoginBackground(
constraints: const BoxConstraints(maxWidth: 420), child: SafeArea(
child: Card( child: Center(
elevation: 8, child: SingleChildScrollView(
shape: RoundedRectangleBorder( padding: const EdgeInsets.symmetric(
borderRadius: BorderRadius.circular(24), horizontal: 20,
), vertical: 24,
child: Padding( ),
padding: const EdgeInsets.all(20), child: ConstrainedBox(
child: Form( constraints: const BoxConstraints(maxWidth: 440),
key: _formKey, child: Card(
child: Column( elevation: 0,
mainAxisSize: MainAxisSize.min, color: colorScheme.surface.withOpacity(
crossAxisAlignment: CrossAxisAlignment.stretch, Theme.of(context).brightness == Brightness.dark
children: [ ? 0.75
Text( : 0.92,
'E-Receipt', ),
textAlign: TextAlign.center, shape: RoundedRectangleBorder(
style: Theme.of(context).textTheme.headlineSmall, borderRadius: BorderRadius.circular(28),
side: BorderSide(
color: colorScheme.outlineVariant.withOpacity(0.35),
), ),
const SizedBox(height: 20), ),
//Username child: Padding(
roundedInput( padding: const EdgeInsets.fromLTRB(20, 22, 20, 18),
controller: _usernameController, child: AutofillGroup(
hint: "Username", child: Form(
icon: Icons.person, key: formKey,
validator: (value) { autovalidateMode: attemptedSubmit
if ((value ?? '').trim().isEmpty) { ? AutovalidateMode.onUserInteraction
return 'Username is required'; : AutovalidateMode.disabled,
} child: Column(
return null; mainAxisSize: MainAxisSize.min,
}, crossAxisAlignment: CrossAxisAlignment.stretch,
), children: [
const SizedBox(height: 12), Row(
//Password children: [
roundedInput( Container(
controller: _passwordController, height: 44,
hint: "Password", width: 44,
icon: Icons.lock, decoration: BoxDecoration(
isPassword: true, borderRadius: BorderRadius.circular(14),
validator: (value) { gradient: LinearGradient(
if ((value ?? '').isEmpty) { begin: Alignment.topLeft,
return 'Password is required'; end: Alignment.bottomRight,
} colors: [
if (value!.length < 6) { colorScheme.primary,
return 'Password must be at least 6 characters'; colorScheme.tertiary,
} ],
return null; ),
}, ),
), child: Icon(
const SizedBox(height: 20), Icons.receipt_long,
FilledButton( color: colorScheme.onPrimary,
onPressed: state.isLoading ),
? null ),
: () { const SizedBox(width: 12),
ref Expanded(
.read(loginViewModelProvider.notifier) child: Column(
.clearMessages(); crossAxisAlignment:
if (_formKey.currentState?.validate() ?? CrossAxisAlignment.start,
false) { children: [
ref Text(
.read(loginViewModelProvider.notifier) 'E-Receipt',
.login( style: Theme.of(context)
username: _usernameController.text, .textTheme
password: _passwordController.text, .titleLarge
); ?.copyWith(
} fontWeight: FontWeight.w700,
}, ),
child: state.isLoading ),
? const SizedBox( Text(
height: 20, 'Sign in to continue',
width: 20, style: Theme.of(context)
child: CircularProgressIndicator( .textTheme
strokeWidth: 2, .bodyMedium
?.copyWith(
color: colorScheme
.onSurfaceVariant,
),
),
],
),
),
],
),
const SizedBox(height: 18),
LoginErrorBanner(message: state.errorMessage),
if (state.errorMessage != null)
const SizedBox(height: 14),
TextFormField(
controller: usernameController,
focusNode: usernameFocusNode,
enabled: !state.isLoading,
textInputAction: TextInputAction.next,
autofillHints: const [AutofillHints.username],
onFieldSubmitted: (_) =>
passwordFocusNode.requestFocus(),
validator: (value) {
if ((value ?? '').trim().isEmpty) {
return 'Username is required';
}
return null;
},
decoration: InputDecoration(
labelText: 'Username',
hintText: 'Enter your username',
prefixIcon: const Icon(Icons.person_outline),
filled: true,
fillColor: colorScheme.surfaceContainerHighest
.withOpacity(
Theme.of(context).brightness ==
Brightness.dark
? 0.55
: 0.9,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
),
), ),
) ),
: const Text('Login'), const SizedBox(height: 12),
TextFormField(
controller: passwordController,
focusNode: passwordFocusNode,
enabled: !state.isLoading,
textInputAction: TextInputAction.done,
autofillHints: const [AutofillHints.password],
obscureText: obscurePassword,
onFieldSubmitted: (_) => _submit(
ref: ref,
state: state,
formKey: formKey,
usernameController: usernameController,
passwordController: passwordController,
),
validator: (value) {
if ((value ?? '').isEmpty) {
return 'Password is required';
}
if (value!.length < 6) {
return 'Password must be at least 6 characters';
}
return null;
},
decoration: InputDecoration(
labelText: 'Password',
hintText: 'Enter your password',
prefixIcon: const Icon(Icons.lock_outline),
filled: true,
fillColor: colorScheme.surfaceContainerHighest
.withOpacity(
Theme.of(context).brightness ==
Brightness.dark
? 0.55
: 0.9,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
),
suffixIcon: IconButton(
tooltip: obscurePassword
? 'Show password'
: 'Hide password',
onPressed: state.isLoading
? null
: () => ref
.read(
loginObscurePasswordProvider
.notifier,
)
.state =
!obscurePassword,
icon: Icon(
obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
),
),
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: state.isLoading
? null
: () => _submit(
ref: ref,
state: state,
formKey: formKey,
usernameController: usernameController,
passwordController: passwordController,
),
icon: state.isLoading
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.login),
label: Padding(
padding: const EdgeInsets.symmetric(
vertical: 12,
),
child: Text(
state.isLoading
? 'Signing in...'
: 'Sign in',
style: const TextStyle(
fontWeight: FontWeight.w600,
),
),
),
),
const SizedBox(height: 12),
Text(
"By continuing, you agree to your organization's policies.",
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
), ),
], ),
), ),
), ),
), ),
@ -147,3 +293,4 @@ class _LoginPageState extends ConsumerState<LoginPage> {
); );
} }
} }

View File

@ -1,5 +1,7 @@
import 'package:e_receipt_mobile/domain/entities/login_user.dart'; import 'package:e_receipt_mobile/domain/entities/login_user.dart';
import 'package:flutter/foundation.dart';
@immutable
class LoginState { class LoginState {
const LoginState({ const LoginState({
this.isLoading = false, this.isLoading = false,
@ -13,15 +15,14 @@ class LoginState {
final String? successMessage; final String? successMessage;
final LoginUser? user; final LoginUser? user;
static const Object _unset = Object();
LoginState copyWith({ LoginState copyWith({
bool? isLoading, bool? isLoading,
String? errorMessage, String? errorMessage,
String? successMessage, String? successMessage,
Object? user = _unset, LoginUser? user,
bool clearError = false, bool clearError = false,
bool clearSuccess = false, bool clearSuccess = false,
bool clearUser = false,
}) { }) {
return LoginState( return LoginState(
isLoading: isLoading ?? this.isLoading, isLoading: isLoading ?? this.isLoading,
@ -29,7 +30,12 @@ class LoginState {
successMessage: clearSuccess successMessage: clearSuccess
? null ? null
: successMessage ?? this.successMessage, : successMessage ?? this.successMessage,
user: identical(user, _unset) ? this.user : user as LoginUser?, user: clearUser ? null : user ?? this.user,
); );
} }
@override
String toString() {
return 'LoginState(isLoading: $isLoading, errorMessage: $errorMessage, successMessage: $successMessage, user: $user)';
}
} }

View File

@ -1,4 +1,5 @@
import 'package:e_receipt_mobile/core/config/app_config.dart'; import 'package:e_receipt_mobile/core/config/app_config.dart';
import 'package:e_receipt_mobile/core/network/api_key_http_client.dart';
import 'package:e_receipt_mobile/core/network/auth_http_client.dart'; import 'package:e_receipt_mobile/core/network/auth_http_client.dart';
import 'package:e_receipt_mobile/core/network/logging_http_client.dart'; import 'package:e_receipt_mobile/core/network/logging_http_client.dart';
import 'package:e_receipt_mobile/data/repositories/api_auth_repository.dart'; import 'package:e_receipt_mobile/data/repositories/api_auth_repository.dart';
@ -9,18 +10,28 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
final rawHttpClientProvider = Provider<http.Client>((ref) { final rawHttpClientProvider = Provider<http.Client>((ref) {
final client = LoggingHttpClient(); final client = ApiKeyHttpClient(
innerClient: LoggingHttpClient(),
apiSecret: AppConfig.apiSecret,
);
ref.onDispose(client.close); ref.onDispose(client.close);
return client; return client;
}); });
final authenticatedHttpClientProvider = Provider<http.Client>((ref) { final authenticatedHttpClientProvider = Provider<http.Client>((ref) {
final client = AuthHttpClient( final client = AuthHttpClient(
innerClient: LoggingHttpClient(), innerClient: ApiKeyHttpClient(
innerClient: LoggingHttpClient(),
apiSecret: AppConfig.apiSecret,
),
accessTokenProvider: () => ref.read(sessionControllerProvider)?.token, accessTokenProvider: () => ref.read(sessionControllerProvider)?.token,
shouldRefreshOnUnauthorized: (uri) => shouldRefreshOnUnauthorized: (uri) {
!uri.path.endsWith('/receipt/auth/login') && final path = uri.path;
!uri.path.endsWith('/receipt/auth/refresh-token'), return !(path.endsWith('/auth/login') ||
path.endsWith('/auth/refresh-token') ||
path.endsWith('/receipt/auth/login') ||
path.endsWith('/receipt/auth/refresh-token'));
},
refreshAccessToken: () async { refreshAccessToken: () async {
final sessionUser = ref.read(sessionControllerProvider); final sessionUser = ref.read(sessionControllerProvider);
if (sessionUser == null) { if (sessionUser == null) {
@ -28,11 +39,13 @@ final authenticatedHttpClientProvider = Provider<http.Client>((ref) {
} }
try { try {
final refreshedUser = await ref.read(authRepositoryProvider).refreshSession( final refreshedUser = await ref
username: sessionUser.username, .read(authRepositoryProvider)
refreshToken: sessionUser.refreshToken, .refreshSession(
role: sessionUser.role, username: sessionUser.username,
); refreshToken: sessionUser.refreshToken,
role: sessionUser.role,
);
ref.read(sessionControllerProvider.notifier).setUser(refreshedUser); ref.read(sessionControllerProvider.notifier).setUser(refreshedUser);
return refreshedUser.token; return refreshedUser.token;
} catch (_) { } catch (_) {
@ -68,7 +81,10 @@ class LoginViewModel extends StateNotifier<LoginState> {
final AuthRepository _authRepository; final AuthRepository _authRepository;
final SessionController _sessionController; final SessionController _sessionController;
Future<void> login({required String username, required String password}) async { Future<void> login({
required String username,
required String password,
}) async {
final trimmedUsername = username.trim(); final trimmedUsername = username.trim();
if (trimmedUsername.isEmpty || password.isEmpty) { if (trimmedUsername.isEmpty || password.isEmpty) {
state = state.copyWith( state = state.copyWith(
@ -83,7 +99,7 @@ class LoginViewModel extends StateNotifier<LoginState> {
isLoading: true, isLoading: true,
clearError: true, clearError: true,
clearSuccess: true, clearSuccess: true,
user: null, clearUser: true,
); );
_sessionController.clearUser(); _sessionController.clearUser();

View File

@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
class LoginBackground extends StatelessWidget {
const LoginBackground({required this.child, super.key});
final Widget child;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Stack(
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
colorScheme.primary.withOpacity(0.18),
colorScheme.secondary.withOpacity(0.10),
colorScheme.surface,
],
stops: const [0.0, 0.45, 1.0],
),
),
),
),
Positioned(
top: -120,
right: -120,
child: _GlowBlob(color: colorScheme.primary.withOpacity(0.20)),
),
Positioned(
bottom: -150,
left: -150,
child: _GlowBlob(color: colorScheme.tertiary.withOpacity(0.18)),
),
child,
],
);
}
}
class _GlowBlob extends StatelessWidget {
const _GlowBlob({required this.color});
final Color color;
@override
Widget build(BuildContext context) {
return IgnorePointer(
child: Container(
height: 280,
width: 280,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color,
boxShadow: [
BoxShadow(color: color, blurRadius: 80, spreadRadius: 40),
],
),
),
);
}
}

View File

@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
class LoginErrorBanner extends StatelessWidget {
const LoginErrorBanner({required this.message, super.key});
final String? message;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: message == null
? const SizedBox.shrink()
: Container(
key: ValueKey(message),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: colorScheme.errorContainer.withOpacity(0.9),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: colorScheme.error.withOpacity(0.25)),
),
child: Row(
children: [
Icon(
Icons.error_outline,
color: colorScheme.onErrorContainer,
),
const SizedBox(width: 10),
Expanded(
child: Text(
message!,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onErrorContainer,
),
),
),
],
),
),
);
}
}

View File

@ -0,0 +1,92 @@
import 'dart:io';
import 'package:e_receipt_mobile/core/config/app_config.dart';
import 'package:e_receipt_mobile/data/repositories/api_receipt_repository.dart';
import 'package:e_receipt_mobile/domain/entities/receipt_content.dart';
import 'package:e_receipt_mobile/domain/repositories/receipt_repository.dart';
import 'package:e_receipt_mobile/presentation/auth/session_controller.dart';
import 'package:e_receipt_mobile/presentation/login/login_view_model.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class TransactionReceiptQuery {
const TransactionReceiptQuery({
required this.transactionId,
required this.copyFor,
});
final String transactionId;
final String copyFor;
@override
bool operator ==(Object other) {
return other is TransactionReceiptQuery &&
other.transactionId == transactionId &&
other.copyFor == copyFor;
}
@override
int get hashCode => Object.hash(transactionId, copyFor);
}
sealed class ReceiptViewData {
const ReceiptViewData();
}
class ReceiptPdfViewData extends ReceiptViewData {
const ReceiptPdfViewData(this.file);
final File file;
}
class ReceiptHtmlViewData extends ReceiptViewData {
const ReceiptHtmlViewData(this.html);
final String html;
}
final receiptRepositoryProvider = Provider<ReceiptRepository>((ref) {
return ApiReceiptRepository(
baseUrl: AppConfig.apiBaseUrl,
apiSecret: AppConfig.apiSecret,
client: ref.watch(authenticatedHttpClientProvider),
);
});
final transactionReceiptViewDataProvider =
FutureProvider.family<ReceiptViewData, TransactionReceiptQuery>((
ref,
query,
) async {
try {
final sessionUser = ref.watch(sessionControllerProvider);
if (sessionUser == null) {
throw Exception('No active session');
}
final content = await ref
.watch(receiptRepositoryProvider)
.getTransactionReceipt(
token: sessionUser.token,
transactionId: query.transactionId,
copyFor: query.copyFor,
);
switch (content) {
case ReceiptPdfContent():
final dir = await Directory.systemTemp.createTemp(
'e_receipt_mobile',
);
final file = File(
'${dir.path}/receipt_${query.transactionId}_${query.copyFor}.pdf',
);
await file.writeAsBytes(content.bytes, flush: true);
return ReceiptPdfViewData(file);
case ReceiptHtmlContent():
return ReceiptHtmlViewData(content.html);
}
} catch (e, st) {
debugPrint('[RECEIPT][ERROR] ${Error.safeToString(e)}\n$st');
throw Exception(Error.safeToString(e));
}
});

View File

@ -26,141 +26,542 @@ class _TerminalNextScreenState extends ConsumerState<TerminalNextScreen> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final serial = widget.terminal.serial?.trim() ?? ''; final serial = widget.terminal.serial?.trim() ?? '';
final hasSerial = serial.isNotEmpty; final hasSerial = serial.isNotEmpty;
final query = TransactionQuery(serial: serial, range: _selectedRange);
final transactionsAsync = hasSerial final transactionsAsync = hasSerial
? ref.watch( ? ref.watch(transactionListProvider(query))
transactionListProvider(
TransactionQuery(serial: serial, range: _selectedRange),
),
)
: null; : null;
final colorScheme = Theme.of(context).colorScheme;
final terminalName =
_sanitizeMultiline(widget.terminal.name) ?? widget.terminal.tid ?? '-';
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Terminal Transactions')), appBar: AppBar(title: Text(terminalName)),
body: Padding( body: SafeArea(
padding: const EdgeInsets.all(16), child: RefreshIndicator.adaptive(
child: Column( onRefresh: () async {
crossAxisAlignment: CrossAxisAlignment.start, if (!hasSerial) {
children: [ return;
Text( }
'Merchant: ${widget.merchantName}', await ref.refresh(transactionListProvider(query).future);
style: Theme.of(context).textTheme.titleMedium, },
), child: CustomScrollView(
const SizedBox(height: 8), physics: const AlwaysScrollableScrollPhysics(),
Text( slivers: [
'Terminal: ${widget.terminal.name ?? widget.terminal.tid ?? '-'}', SliverToBoxAdapter(
), child: Padding(
const SizedBox(height: 4), padding: const EdgeInsets.fromLTRB(16, 16, 16, 10),
Text('TID: ${widget.terminal.tid ?? '-'}'), child: Card(
const SizedBox(height: 4), elevation: 0,
Text( color: colorScheme.surfaceContainerLow,
'MIDs: ${(widget.terminal.mids == null || widget.terminal.mids!.isEmpty) ? '-' : widget.terminal.mids!.join(', ')}', shape: RoundedRectangleBorder(
), borderRadius: BorderRadius.circular(22),
const SizedBox(height: 4), side: BorderSide(
Text('Serial: ${widget.terminal.serial ?? '-'}'), color: colorScheme.outlineVariant.withOpacity(0.55),
const SizedBox(height: 4), ),
Text('Address: ${widget.terminal.address ?? '-'}'), ),
const SizedBox(height: 16), child: Padding(
Row( padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
children: [ child: Column(
Text( crossAxisAlignment: CrossAxisAlignment.stretch,
'Transactions', children: [
style: Theme.of(context).textTheme.titleMedium, Row(
children: [
Container(
height: 44,
width: 44,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
color: colorScheme.primary.withOpacity(0.14),
),
child: Icon(
Icons.point_of_sale_outlined,
color: colorScheme.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.merchantName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight: FontWeight.w800,
),
),
Text(
terminalName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
const SizedBox(height: 12),
_InfoRow(
label: 'Serial',
value: widget.terminal.serial,
),
_InfoRow(
label: 'Address',
value: _sanitizeMultiline(widget.terminal.address),
),
],
),
),
),
), ),
const Spacer(), ),
const Text('Range: '), SliverToBoxAdapter(
const SizedBox(width: 8), child: Padding(
DropdownButton<String>( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
value: _selectedRange, child: Row(
items: _ranges children: [
.map( Text(
(range) => DropdownMenuItem<String>( 'Transactions',
value: range, style: Theme.of(context).textTheme.titleMedium
child: Text(range), ?.copyWith(fontWeight: FontWeight.w800),
),
const Spacer(),
SegmentedButton<String>(
segments: _ranges
.map(
(range) => ButtonSegment<String>(
value: range,
label: Text(range.toUpperCase()),
),
)
.toList(),
selected: <String>{_selectedRange},
onSelectionChanged: (value) {
final next = value.firstOrNull;
if (next == null || next == _selectedRange) {
return;
}
setState(() => _selectedRange = next);
},
style: ButtonStyle(
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
), ),
) ),
.toList(), ],
onChanged: (value) { ),
if (value == null || value == _selectedRange) { ),
return; ),
if (!hasSerial)
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.info_outline,
size: 56,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 12),
Text(
'Serial is required to load transactions',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 6),
Text(
'This terminal does not have a serial number.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
),
),
),
)
else
...transactionsAsync!.when(
loading: () => [
const SliverFillRemaining(
child: Center(child: CircularProgressIndicator()),
),
],
error: (error, _) => [
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.wifi_off_outlined,
size: 56,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 12),
Text(
'Failed to load transactions',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 6),
Text(
'$error',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 14),
FilledButton.tonal(
onPressed: () => ref.invalidate(
transactionListProvider(query),
),
child: const Text('Try again'),
),
],
),
),
),
),
],
data: (transactions) {
if (transactions.isEmpty) {
return [
SliverFillRemaining(
hasScrollBody: false,
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.receipt_long_outlined,
size: 56,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 12),
Text(
'No transactions found',
textAlign: TextAlign.center,
style: Theme.of(
context,
).textTheme.titleMedium,
),
const SizedBox(height: 6),
Text(
'Pull down to refresh.',
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
),
),
];
} }
setState(() {
_selectedRange = value; return [
}); SliverPadding(
padding: EdgeInsets.fromLTRB(
16,
0,
16,
MediaQuery.viewPaddingOf(context).bottom + 16,
),
sliver: SliverList.separated(
itemCount: transactions.length,
separatorBuilder: (_, __) =>
const SizedBox(height: 10),
itemBuilder: (context, index) {
final item = transactions[index];
final raw = item.raw ?? const <String, dynamic>{};
final amount =
_first(raw, const ['DE4']) ??
item.amount?.toString() ??
'-';
final currency =
_first(raw, const ['DE49']) ?? 'MMK';
final status = _statusLabel(
_first(raw, const [
'description',
'DE39',
'status',
]) ??
item.status,
);
final type =
_first(raw, const ['DE3']) ?? item.type ?? '-';
final rrn =
_first(raw, const ['DE37']) ?? item.rrn ?? '-';
final createdAt =
_first(raw, const ['CREATED_AT', 'de7_date']) ??
item.createdAt;
return Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
side: BorderSide(
color: colorScheme.outlineVariant.withOpacity(
0.55,
),
),
),
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => TransactionReceiptScreen(
merchantName: widget.merchantName,
terminal: widget.terminal,
transaction: item,
),
),
);
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
child: Row(
children: [
Container(
height: 44,
width: 44,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
14,
),
color: colorScheme.primary
.withOpacity(0.14),
),
child: Icon(
Icons.receipt_long_outlined,
color: colorScheme.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
'$amount $currency',
maxLines: 1,
overflow:
TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight:
FontWeight.w800,
),
),
),
const SizedBox(width: 8),
_StatusChip(status: status),
],
),
const SizedBox(height: 4),
Text(
'Type: $type • RRN: $rrn',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: colorScheme
.onSurfaceVariant,
),
),
if ((createdAt ?? '')
.trim()
.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
createdAt!.trim(),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: colorScheme
.onSurfaceVariant,
),
),
],
],
),
),
const SizedBox(width: 8),
Icon(
Icons.chevron_right,
color: colorScheme.onSurfaceVariant,
),
],
),
),
),
);
},
),
),
];
}, },
), ),
], ],
),
),
),
);
}
String? _first(Map<String, dynamic> raw, List<String> keys) {
for (final key in keys) {
final value = raw[key];
if (value != null) {
final text = value.toString().trim();
if (text.isNotEmpty) {
return text;
}
}
}
return null;
}
String _statusLabel(String? status) {
final normalized = (status ?? '').trim().toUpperCase();
if (normalized == 'A' ||
normalized == 'SUCCESS' ||
normalized == 'PAY_SUCCESS') {
return 'SUCCESS';
}
if (normalized == 'E' || normalized == 'FAILED') {
return 'FAILED';
}
return normalized.isEmpty ? '-' : normalized;
}
String? _sanitizeMultiline(String? value) {
if (value == null) {
return null;
}
final cleaned = value
.replaceAll('\r', ' ')
.replaceAll('\n', ' ')
.replaceAll(r'\n', ' ')
.replaceAll(r'/n', ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
return cleaned.isEmpty ? null : cleaned;
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.label, required this.value});
final String label;
final String? value;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final text = (value ?? '').trim().isEmpty ? '-' : value!.trim();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Expanded(
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
), ),
const SizedBox(height: 8), ),
Expanded( const SizedBox(width: 12),
child: !hasSerial Expanded(
? const Center( child: Text(
child: Text('Serial is required to load transactions'), text,
) textAlign: TextAlign.right,
: transactionsAsync!.when( maxLines: 1,
loading: () => overflow: TextOverflow.ellipsis,
const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Text('Failed to load transactions: $error'),
),
data: (transactions) {
return RefreshIndicator(
onRefresh: () => ref.refresh(
transactionListProvider(
TransactionQuery(
serial: serial,
range: _selectedRange,
),
).future,
),
child: transactions.isEmpty
? ListView(
physics:
const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(height: 120),
Center(child: Text('No transactions found')),
],
)
: ListView.separated(
physics:
const AlwaysScrollableScrollPhysics(),
itemCount: transactions.length,
separatorBuilder: (_, __) =>
const SizedBox(height: 8),
itemBuilder: (context, index) {
final item = transactions[index];
return Card(
child: ListTile(
onTap: () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
TransactionReceiptScreen(
merchantName:
widget.merchantName,
terminal: widget.terminal,
transaction: item,
),
),
);
},
title: Text(
'Amount: ${item.amount?.toString() ?? '-'}',
),
subtitle: Text(
'Status: ${item.status ?? '-'} | Type: ${item.type ?? '-'} | RRN: ${item.rrn ?? '-'}\nTap to view receipt detail',
),
trailing:
const Icon(Icons.receipt_long_outlined),
),
);
},
),
);
},
),
), ),
], ),
],
),
);
}
}
class _StatusChip extends StatelessWidget {
const _StatusChip({required this.status});
final String status;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final normalized = status.toUpperCase();
final isSuccess = normalized == 'SUCCESS';
final background = isSuccess
? Colors.green.withOpacity(0.12)
: colorScheme.errorContainer.withOpacity(0.9);
final foreground = isSuccess
? Colors.green.shade700
: colorScheme.onErrorContainer;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: foreground.withOpacity(0.25)),
),
child: Text(
normalized.isEmpty ? '-' : normalized,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w800,
color: foreground,
), ),
), ),
); );

View File

@ -1,10 +1,12 @@
import 'package:e_receipt_mobile/domain/entities/terminal.dart'; import 'package:e_receipt_mobile/domain/entities/terminal.dart';
import 'package:e_receipt_mobile/presentation/home/home_view_model.dart'; import 'package:e_receipt_mobile/presentation/home/home_view_model.dart';
import 'package:e_receipt_mobile/presentation/terminal/terminal_next_screen.dart'; import 'package:e_receipt_mobile/presentation/terminal/terminal_next_screen.dart';
import 'package:e_receipt_mobile/presentation/terminal/widgets/terminal_header.dart';
import 'package:e_receipt_mobile/presentation/terminal/widgets/terminal_list_view.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
class TerminalSelectionScreen extends ConsumerStatefulWidget { class TerminalSelectionScreen extends ConsumerWidget {
const TerminalSelectionScreen({ const TerminalSelectionScreen({
required this.merchantId, required this.merchantId,
required this.merchantName, required this.merchantName,
@ -15,58 +17,92 @@ class TerminalSelectionScreen extends ConsumerStatefulWidget {
final String merchantName; final String merchantName;
@override @override
ConsumerState<TerminalSelectionScreen> createState() => Widget build(BuildContext context, WidgetRef ref) {
_TerminalSelectionScreenState(); final terminalsAsync = ref.watch(terminalListProvider(merchantId));
} final colorScheme = Theme.of(context).colorScheme;
class _TerminalSelectionScreenState
extends ConsumerState<TerminalSelectionScreen> {
@override
Widget build(BuildContext context) {
final terminalsAsync = ref.watch(terminalListProvider(widget.merchantId));
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(widget.merchantName), title: Text(merchantName),
actions: [
IconButton(
tooltip: 'Refresh',
onPressed: terminalsAsync.isLoading
? null
: () => ref.invalidate(terminalListProvider(merchantId)),
icon: const Icon(Icons.refresh),
),
],
), ),
body: terminalsAsync.when( body: SafeArea(
loading: () => const Center(child: CircularProgressIndicator()), bottom: true,
error: (error, _) => Center(child: Text('Failed to load terminals: $error')), child: terminalsAsync.when(
data: (terminals) { loading: () => const Center(child: CircularProgressIndicator()),
return RefreshIndicator( error: (error, _) => Center(
onRefresh: () => child: Padding(
ref.refresh(terminalListProvider(widget.merchantId).future), padding: EdgeInsets.fromLTRB(
child: terminals.isEmpty 24,
? ListView( 24,
physics: const AlwaysScrollableScrollPhysics(), 24,
padding: const EdgeInsets.all(16), MediaQuery.viewPaddingOf(context).bottom + 24,
children: const [ ),
SizedBox(height: 120), child: Column(
Center(child: Text('No terminals found')), mainAxisSize: MainAxisSize.min,
], children: [
) Icon(
: ListView.separated( Icons.wifi_off_outlined,
physics: const AlwaysScrollableScrollPhysics(), size: 56,
padding: const EdgeInsets.all(16), color: colorScheme.onSurfaceVariant,
itemCount: terminals.length, ),
separatorBuilder: (_, __) => const SizedBox(height: 8), const SizedBox(height: 12),
itemBuilder: (context, index) { Text(
final terminal = terminals[index]; 'Failed to load terminals',
return Card( style: Theme.of(context).textTheme.titleMedium,
child: ListTile( textAlign: TextAlign.center,
onTap: () => _openNextScreen(context, terminal), ),
leading: const Icon(Icons.point_of_sale), const SizedBox(height: 6),
trailing: const Icon(Icons.chevron_right), Text(
title: Text(terminal.name ?? terminal.tid ?? '-'), '$error',
subtitle: Text( style: Theme.of(context).textTheme.bodyMedium?.copyWith(
'TID: ${terminal.tid ?? '-'} | MIDs: ${(terminal.mids == null || terminal.mids!.isEmpty) ? '-' : terminal.mids!.join(', ')}', color: colorScheme.onSurfaceVariant,
), ),
), textAlign: TextAlign.center,
),
const SizedBox(height: 14),
FilledButton.tonal(
onPressed: () =>
ref.invalidate(terminalListProvider(merchantId)),
child: const Text('Try again'),
),
],
),
),
),
data: (terminals) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TerminalHeader(
merchantName: merchantName,
totalCount: terminals.length,
),
Expanded(
child: TerminalListView(
terminals: terminals,
emptyMessage: 'No terminals found',
onRefresh: () async {
await ref.refresh(
terminalListProvider(merchantId).future,
); );
}, },
onTerminalTap: (terminal) =>
_openNextScreen(context, terminal),
), ),
); ),
}, ],
);
},
),
), ),
); );
} }
@ -74,10 +110,8 @@ class _TerminalSelectionScreenState
void _openNextScreen(BuildContext context, Terminal terminal) { void _openNextScreen(BuildContext context, Terminal terminal) {
Navigator.of(context).push( Navigator.of(context).push(
MaterialPageRoute<void>( MaterialPageRoute<void>(
builder: (_) => TerminalNextScreen( builder: (_) =>
merchantName: widget.merchantName, TerminalNextScreen(merchantName: merchantName, terminal: terminal),
terminal: terminal,
),
), ),
); );
} }

View File

@ -1,8 +1,15 @@
import 'dart:io';
import 'package:e_receipt_mobile/domain/entities/terminal.dart'; import 'package:e_receipt_mobile/domain/entities/terminal.dart';
import 'package:e_receipt_mobile/domain/entities/transaction_record.dart'; import 'package:e_receipt_mobile/domain/entities/transaction_record.dart';
import 'package:e_receipt_mobile/presentation/terminal/receipt_view_model.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_pdfview/flutter_pdfview.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:webview_flutter/webview_flutter.dart';
class TransactionReceiptScreen extends StatelessWidget { class TransactionReceiptScreen extends ConsumerWidget {
const TransactionReceiptScreen({ const TransactionReceiptScreen({
required this.merchantName, required this.merchantName,
required this.terminal, required this.terminal,
@ -15,130 +22,320 @@ class TransactionReceiptScreen extends StatelessWidget {
final TransactionRecord transaction; final TransactionRecord transaction;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context, WidgetRef ref) {
final raw = transaction.raw ?? const <String, dynamic>{}; final transactionId = transaction.id?.trim();
final amount = _first(raw, const ['DE4']) ?? transaction.amount?.toString(); if (transactionId == null || transactionId.isEmpty) {
final currency = _first(raw, const ['DE49']) ?? 'MMK'; return Scaffold(
final status = _statusLabel( appBar: AppBar(title: const Text('Receipt')),
_first(raw, const ['description', 'DE39', 'status']) ?? transaction.status, body: const SafeArea(
child: Center(child: Text('Missing transaction id')),
),
);
}
final pdfAsync = ref.watch(
transactionReceiptViewDataProvider(
TransactionReceiptQuery(
transactionId: transactionId,
copyFor: 'Merchant',
),
),
); );
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Receipt Detail')), appBar: AppBar(
body: SafeArea( title: const Text('Receipt'),
child: SingleChildScrollView( actions: [
padding: const EdgeInsets.all(16), IconButton(
child: Card( tooltip: 'Reload',
child: Padding( onPressed: () => ref.invalidate(
padding: const EdgeInsets.all(16), transactionReceiptViewDataProvider(
child: DefaultTextStyle( TransactionReceiptQuery(
style: Theme.of(context).textTheme.bodyMedium!, transactionId: transactionId,
child: Column( copyFor: 'Merchant',
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Center(
child: Text(
'E-Receipt',
style: Theme.of(context).textTheme.titleLarge,
),
),
const SizedBox(height: 4),
Center(child: Text(merchantName)),
const SizedBox(height: 12),
const Text('--------------------------------'),
_line('Terminal', _first(raw, const ['DE41']) ?? terminal.tid),
_line('Merchant ID', _first(raw, const ['DE42'])),
_line('Serial', terminal.serial),
_line(
'Invoice',
_first(raw, const ['invoice_number', 'invoiceNo']),
),
_line('Date/Time', _first(raw, const ['CREATED_AT', 'de7_date'])),
_line('Trace No', _first(raw, const ['DE11'])),
_line('RRN', _first(raw, const ['DE37']) ?? transaction.rrn),
_line('Auth Code', _first(raw, const ['DE38'])),
_line('Type', _first(raw, const ['DE3']) ?? transaction.type),
const Text('--------------------------------'),
const SizedBox(height: 8),
Text(
'AMOUNT',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 4),
Text(
'${amount ?? '-'} $currency',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
status,
textAlign: TextAlign.center,
style: TextStyle(
fontWeight: FontWeight.w700,
color: status == 'SUCCESS'
? Colors.green.shade700
: Colors.red.shade700,
),
),
const SizedBox(height: 12),
const Text('--------------------------------'),
Center(
child: Text(
'Thank you',
style: Theme.of(context).textTheme.bodySmall,
),
),
],
), ),
), ),
), ),
icon: const Icon(Icons.refresh),
), ),
],
),
body: SafeArea(
bottom: true,
child: pdfAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Padding(
padding: EdgeInsets.fromLTRB(
24,
24,
24,
MediaQuery.viewPaddingOf(context).bottom + 24,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.picture_as_pdf_outlined, size: 56),
const SizedBox(height: 12),
Text(
'Failed to load receipt PDF',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
Text(
Error.safeToString(error),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 14),
FilledButton.tonal(
onPressed: () => ref.invalidate(
transactionReceiptViewDataProvider(
TransactionReceiptQuery(
transactionId: transactionId,
copyFor: 'Merchant',
),
),
),
child: const Text('Try again'),
),
],
),
),
),
data: (viewData) => switch (viewData) {
ReceiptPdfViewData() => _ReceiptViewport(
child: PDFView(
filePath: viewData.file.path,
enableSwipe: true,
swipeHorizontal: false,
autoSpacing: true,
pageFling: true,
pageSnap: true,
fitEachPage: true,
fitPolicy: FitPolicy.WIDTH,
backgroundColor: Theme.of(context).colorScheme.surface,
onError: (error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('PDF error: ${Error.safeToString(error)}'),
),
);
},
onPageError: (page, error) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'PDF page $page error: ${Error.safeToString(error)}',
),
),
);
},
),
),
ReceiptHtmlViewData() => _ReceiptViewport(
child: _ReceiptHtmlView(html: viewData.html),
),
},
), ),
), ),
); );
} }
}
class _ReceiptViewport extends StatelessWidget {
const _ReceiptViewport({required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final viewPadding = MediaQuery.viewPaddingOf(context);
final size = MediaQuery.sizeOf(context);
final maxHeight = size.height * 0.60;
// Flutter layout uses logical pixels, not real millimeters. We approximate
// "58mm slip" width using the Android baseline of 160dp/in.
const receiptWidthMm = 58.0;
const mmPerInch = 25.4;
const dpPerInch = 160.0;
const receiptWidthDp = receiptWidthMm / mmPerInch * dpPerInch;
Widget _line(String label, String? value) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 2), padding: EdgeInsets.fromLTRB(16, 16, 16, viewPadding.bottom + 16),
child: Row( child: LayoutBuilder(
children: [ builder: (context, constraints) {
Expanded(child: Text(label)), final double targetWidth =
const SizedBox(width: 8), receiptWidthDp.clamp(0, constraints.maxWidth).toDouble();
Expanded( final double targetHeight =
child: Text( maxHeight.clamp(0, constraints.maxHeight).toDouble();
value == null || value.trim().isEmpty ? '-' : value,
textAlign: TextAlign.right, return Align(
alignment: Alignment.center,
child: SizedBox(
width: targetWidth,
height: targetHeight,
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: DecoratedBox(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withOpacity(0.7),
border: Border.all(color: colorScheme.outlineVariant),
borderRadius: BorderRadius.circular(16),
),
child: SizedBox.expand(child: child),
),
),
), ),
), );
], },
), ),
); );
} }
}
String? _first(Map<String, dynamic> raw, List<String> keys) { class _ReceiptHtmlWebView extends StatefulWidget {
for (final key in keys) { const _ReceiptHtmlWebView({required this.html});
final value = raw[key];
if (value != null) { final String html;
final text = value.toString().trim();
if (text.isNotEmpty) { @override
return text; State<_ReceiptHtmlWebView> createState() => _ReceiptHtmlWebViewState();
} }
}
} class _ReceiptHtmlWebViewState extends State<_ReceiptHtmlWebView> {
return null; late final WebViewController _controller;
@override
void initState() {
super.initState();
_controller =
WebViewController()
..setJavaScriptMode(JavaScriptMode.disabled)
..setBackgroundColor(Colors.transparent)
..setNavigationDelegate(
NavigationDelegate(
onNavigationRequest: (request) => NavigationDecision.prevent,
),
)
..loadHtmlString(_wrapHtml(widget.html));
} }
String _statusLabel(String? status) { @override
final normalized = (status ?? '').trim().toUpperCase(); Widget build(BuildContext context) {
if (normalized == 'A' || normalized == 'SUCCESS' || normalized == 'PAY_SUCCESS') { return WebViewWidget(controller: _controller);
return 'SUCCESS';
}
if (normalized == 'E' || normalized == 'FAILED') {
return 'FAILED';
}
return normalized.isEmpty ? '-' : normalized;
} }
} }
class _ReceiptHtmlView extends StatelessWidget {
const _ReceiptHtmlView({required this.html});
final String html;
@override
Widget build(BuildContext context) {
final platform = defaultTargetPlatform;
final supportsWebView =
platform == TargetPlatform.android || platform == TargetPlatform.iOS;
if (!supportsWebView) {
return _ReceiptHtmlFallbackText(html: html);
}
return _ReceiptHtmlWebView(html: html);
}
}
class _ReceiptHtmlFallbackText extends StatelessWidget {
const _ReceiptHtmlFallbackText({required this.html});
final String html;
@override
Widget build(BuildContext context) {
final plainText = _htmlToPlainText(html);
return Padding(
padding: const EdgeInsets.all(16),
child: SelectionArea(
child: DefaultTextStyle(
style:
Theme.of(context).textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
height: 1.35,
) ??
const TextStyle(fontFamily: 'monospace', height: 1.35),
child: Text(plainText),
),
),
);
}
}
String _wrapHtml(String html) {
// Keep backend HTML intact but ensure a sane viewport and remove default
// margins so it looks like the web "receipt iframe" container.
return '''
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
html, body { margin: 0; padding: 0; background: transparent; }
body { -webkit-text-size-adjust: 100%; }
</style>
</head>
<body>$html</body>
</html>
''';
}
String _htmlToPlainText(String html) {
var s = html;
s = s.replaceAll(
RegExp(
r'<(script|style)[^>]*>.*?</\1>',
caseSensitive: false,
dotAll: true,
),
'',
);
s = s.replaceAll(RegExp(r'<br\s*/?>', caseSensitive: false), '\n');
s = s.replaceAll(
RegExp(
r'</(div|p|tr|pre|table|section|header|footer)>',
caseSensitive: false,
),
'\n',
);
s = s.replaceAll(RegExp(r'<li[^>]*>', caseSensitive: false), '');
s = s.replaceAll(RegExp(r'</li>', caseSensitive: false), '\n');
s = s.replaceAll(RegExp(r'<[^>]+>'), '');
s = s.replaceAll('&nbsp;', ' ');
s = s.replaceAll('&amp;', '&');
s = s.replaceAll('&lt;', '<');
s = s.replaceAll('&gt;', '>');
s = s.replaceAll('&quot;', '"');
s = s.replaceAll('&#39;', "'");
s = s.replaceAllMapped(RegExp(r'&#(\d+);'), (m) {
final code = int.tryParse(m.group(1) ?? '');
if (code == null) return '';
return String.fromCharCode(code);
});
s = s.replaceAllMapped(RegExp(r'&#x([0-9a-fA-F]+);'), (m) {
final code = int.tryParse(m.group(1) ?? '', radix: 16);
if (code == null) return '';
return String.fromCharCode(code);
});
s = s.replaceAll(RegExp(r'[ \t]+\n'), '\n');
s = s.replaceAll(RegExp(r'\n{3,}'), '\n\n');
return s.trim();
}

View File

@ -0,0 +1,58 @@
import 'package:flutter/material.dart';
class TerminalHeader extends StatelessWidget {
const TerminalHeader({
required this.merchantName,
required this.totalCount,
super.key,
});
final String merchantName;
final int totalCount;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 10),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Terminals',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
merchantName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: 12),
Chip(
label: Text('$totalCount'),
side: BorderSide(color: colorScheme.outlineVariant),
backgroundColor: colorScheme.surfaceContainerHigh,
),
],
),
],
),
);
}
}

View File

@ -0,0 +1,205 @@
import 'dart:ui';
import 'package:e_receipt_mobile/domain/entities/terminal.dart';
import 'package:flutter/material.dart';
class TerminalListView extends StatelessWidget {
const TerminalListView({
required this.terminals,
required this.onRefresh,
required this.onTerminalTap,
required this.emptyMessage,
super.key,
});
final List<Terminal> terminals;
final Future<void> Function() onRefresh;
final void Function(Terminal terminal) onTerminalTap;
final String emptyMessage;
@override
Widget build(BuildContext context) {
final bottomInset = MediaQuery.viewPaddingOf(context).bottom;
final colorScheme = Theme.of(context).colorScheme;
return RefreshIndicator.adaptive(
onRefresh: onRefresh,
child: terminals.isEmpty
? ListView(
padding: EdgeInsets.fromLTRB(16, 96, 16, bottomInset + 24),
physics: const AlwaysScrollableScrollPhysics(),
children: [
Icon(
Icons.point_of_sale_outlined,
size: 56,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 12),
Text(
emptyMessage,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 6),
Text(
'Pull down to refresh.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
)
: ListView.separated(
padding: EdgeInsets.fromLTRB(16, 8, 16, bottomInset + 16),
physics: const AlwaysScrollableScrollPhysics(),
itemCount: terminals.length,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, index) {
final terminal = terminals[index];
final enabled = terminal.tid != null || terminal.id != null;
final title =
(_sanitizeMultiline(terminal.name)?.isNotEmpty ?? false)
? _sanitizeMultiline(terminal.name)!
: (terminal.tid?.trim().isNotEmpty ?? false)
? terminal.tid!.trim()
: '-';
final status = (terminal.status ?? '').trim();
final serial = (_sanitizeMultiline(terminal.serial) ?? '')
.trim();
return Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
side: BorderSide(
color: colorScheme.outlineVariant.withOpacity(0.55),
),
),
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: enabled ? () => onTerminalTap(terminal) : null,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
child: Row(
children: [
Container(
height: 44,
width: 44,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
color: enabled
? colorScheme.primary.withOpacity(0.14)
: colorScheme.surfaceContainerHigh,
),
child: Icon(
Icons.point_of_sale_outlined,
color: enabled
? colorScheme.primary
: colorScheme.onSurfaceVariant,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
if (status.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
999,
),
color:
colorScheme.surfaceContainerHigh,
border: Border.all(
color: colorScheme.outlineVariant,
),
),
child: Text(
status.toUpperCase(),
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
),
),
),
],
),
if (serial.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
'Serial: $serial',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: colorScheme.onSurfaceVariant,
fontFeatures: const [
FontFeature.tabularFigures(),
],
),
),
],
],
),
),
const SizedBox(width: 8),
Icon(
Icons.chevron_right,
color: enabled
? colorScheme.onSurfaceVariant
: colorScheme.onSurfaceVariant.withOpacity(0.4),
),
],
),
),
),
);
},
),
);
}
}
String? _sanitizeMultiline(String? value) {
if (value == null) {
return null;
}
final cleaned = value
.replaceAll('\r', ' ')
.replaceAll('\n', ' ')
.replaceAll(r'\n', ' ')
.replaceAll(r'/n', ' ')
.replaceAll(RegExp(r'\s+'), ' ')
.trim();
return cleaned.isEmpty ? null : cleaned;
}

View File

@ -21,10 +21,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" version: "1.4.1"
clock: clock:
dependency: transitive dependency: transitive
description: description:
@ -33,6 +33,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
collection: collection:
dependency: transitive dependency: transitive
description: description:
@ -41,6 +49,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.19.1" version: "1.19.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_icons: cupertino_icons:
dependency: "direct main" dependency: "direct main"
description: description:
@ -57,6 +73,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.3" version: "1.3.3"
ffi:
dependency: transitive
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
flutter: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@ -70,6 +102,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" version: "6.0.0"
flutter_pdfview:
dependency: "direct main"
description:
name: flutter_pdfview
sha256: "5b80e89f3ba6e478d1e897543c9634508284ad73476807febc188378986b69ee"
url: "https://pub.dev"
source: hosted
version: "1.4.4"
flutter_riverpod: flutter_riverpod:
dependency: "direct main" dependency: "direct main"
description: description:
@ -83,6 +123,22 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
hooks:
dependency: transitive
description:
name: hooks
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
url: "https://pub.dev"
source: hosted
version: "1.0.2"
http: http:
dependency: "direct main" dependency: "direct main"
description: description:
@ -131,22 +187,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" version: "6.1.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.17" version: "0.12.19"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.11.1" version: "0.13.0"
meta: meta:
dependency: transitive dependency: transitive
description: description:
@ -155,6 +219,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.17.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
url: "https://pub.dev"
source: hosted
version: "0.17.6"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
path: path:
dependency: transitive dependency: transitive
description: description:
@ -163,6 +243,78 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba"
url: "https://pub.dev"
source: hosted
version: "2.2.23"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
source: hosted
version: "2.2.1"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
source: hosted
version: "2.3.0"
platform:
dependency: transitive
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
source: hosted
version: "3.1.6"
plugin_platform_interface:
dependency: transitive
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
riverpod: riverpod:
dependency: transitive dependency: transitive
description: description:
@ -228,10 +380,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.7" version: "0.7.10"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:
@ -264,6 +416,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" version: "1.1.1"
webview_flutter:
dependency: "direct main"
description:
name: webview_flutter
sha256: a3da219916aba44947d3a5478b1927876a09781174b5a2b67fa5be0555154bf9
url: "https://pub.dev"
source: hosted
version: "4.13.1"
webview_flutter_android:
dependency: transitive
description:
name: webview_flutter_android
sha256: "0f7fcd2c86bf36bdcf94881f7941ce0cbc4f8d104b9fdcd5fcbef90e2199db76"
url: "https://pub.dev"
source: hosted
version: "4.10.15"
webview_flutter_platform_interface:
dependency: transitive
description:
name: webview_flutter_platform_interface
sha256: "1221c1b12f5278791042f2ec2841743784cf25c5a644e23d6680e5d718824f04"
url: "https://pub.dev"
source: hosted
version: "2.15.1"
webview_flutter_wkwebview:
dependency: transitive
description:
name: webview_flutter_wkwebview
sha256: d7219cfabc6f5fc2032e0fa980ec36d71f308a35a823395af1abc34d9a2ede83
url: "https://pub.dev"
source: hosted
version: "3.24.2"
xdg_directories:
dependency: transitive
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks: sdks:
dart: ">=3.10.7 <4.0.0" dart: ">=3.10.7 <4.0.0"
flutter: ">=3.18.0-18.0.pre.54" flutter: ">=3.38.4"

View File

@ -36,6 +36,9 @@ dependencies:
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
flutter_riverpod: ^2.6.1 flutter_riverpod: ^2.6.1
http: ^1.2.2 http: ^1.2.2
flutter_pdfview: ^1.4.0
path_provider: ^2.1.5
webview_flutter: ^4.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: