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 {
const AppConfig._();
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://api-tms-uat.kbzbank.com:8443';
static const String apiBaseUrl = 'https://receipt-nest.utsmyanmar.com';
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

@ -17,7 +17,10 @@ class LoggingHttpClient extends http.BaseClient {
try {
final response = await _innerClient.send(request);
final responseBytes = await response.stream.toBytes();
final responseBody = utf8.decode(responseBytes);
final responseBody = _formatBodyForLogging(
responseBytes: responseBytes,
contentType: response.headers['content-type'],
);
_logResponse(response, responseBody);
@ -84,4 +87,32 @@ class LoggingHttpClient extends http.BaseClient {
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 password,
}) async {
final uri = Uri.parse('$baseUrl/receipt/auth/login');
final uri = Uri.parse('$baseUrl/auth/login');
final requestBody = jsonEncode({
'username': username.trim().toLowerCase(),
'password': password,
@ -51,7 +51,7 @@ class ApiAuthRepository implements AuthRepository {
required String refreshToken,
required String role,
}) async {
final uri = Uri.parse('$baseUrl/receipt/auth/refresh-token');
final uri = Uri.parse('$baseUrl/auth/refresh-token');
final requestBody = jsonEncode({
'refreshToken': refreshToken,
});

View File

@ -17,8 +17,20 @@ class ApiMerchantRepository implements MerchantRepository {
final http.Client _client;
@override
Future<List<Merchant>> getMerchants({required String token}) async {
final uri = Uri.parse('$baseUrl/receipt/merchant');
Future<List<Merchant>> getMerchants({
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(
uri,
headers: {
@ -40,7 +52,7 @@ class ApiMerchantRepository implements MerchantRepository {
required String token,
required String merchantId,
}) async {
final uri = Uri.parse('$baseUrl/receipt/terminal/list/$merchantId');
final uri = Uri.parse('$baseUrl/terminal/list/$merchantId');
final response = await _client.get(
uri,
headers: {
@ -172,10 +184,10 @@ class ApiMerchantRepository implements MerchantRepository {
mids: _pickStringList(item, const ['mid', 'mids']),
merchantId: _pickString(item, const ['merchantId', 'merchant_id']),
status: _pickString(item, const ['status']),
totalTransactions: _pickInt(
item,
const ['totalTransactions', 'total_transactions'],
),
totalTransactions: _pickInt(item, const [
'totalTransactions',
'total_transactions',
]),
totalAmount: _pickNum(item, const ['totalAmount', 'total_amount']),
createdAt: _pickString(item, const ['createdAt', 'created_at']),
updatedAt: _pickString(item, const ['updatedAt', 'updated_at']),
@ -247,7 +259,8 @@ class ApiMerchantRepository implements MerchantRepository {
String _extractErrorMessage(String body) {
final decoded = _tryDecode(body);
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) {
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 range,
}) async {
final uri = Uri.parse('$baseUrl/receipt/transaction').replace(
final uri = Uri.parse('$baseUrl/transaction').replace(
queryParameters: {
'serial': serial,
'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';
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({
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/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/settings/theme_mode_provider.dart';
import 'package:flutter/material.dart';
@ -65,7 +67,20 @@ class EReceiptApp extends ConsumerWidget {
),
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/presentation/auth/session_controller.dart';
import 'package:e_receipt_mobile/presentation/home/home_view_model.dart';
import 'package:e_receipt_mobile/presentation/login/login_page.dart';
import 'package:e_receipt_mobile/presentation/home/home_pagination_providers.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/terminal/terminal_selection_screen.dart';
import 'package:flutter/material.dart';
@ -15,128 +17,51 @@ class HomeScreen extends ConsumerWidget {
@override
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(
appBar: AppBar(
title: Text("Merchants"),
centerTitle: true,
title: const Text('Merchants'),
actions: [
IconButton(
tooltip: 'Refresh',
onPressed: pagingState.isLoading
? null
: () => pagingViewModel.refresh(),
icon: const Icon(Icons.refresh),
),
drawer: Drawer(
backgroundColor: Colors.white,
child: Column(
children: [
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: () {
drawer: HomeDrawer(
user: user,
onProfile: () {
Navigator.of(context).pop();
_showProfile(context);
_showProfile(context, user);
},
),
// 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: () {
onReports: () {
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: () {
onSettings: () {
Navigator.of(context).pop();
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => const SettingsScreen(),
),
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: () {
onHelp: () {
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 {
onLogout: () 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?'),
content: const Text('Are you sure you want to logout?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
@ -155,77 +80,126 @@ class HomeScreen extends ConsumerWidget {
return;
}
Navigator.of(context).pop(); // close drawer
ref.read(sessionControllerProvider.notifier).clearUser();
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute<void>(
builder: (_) => const LoginPage(),
),
(route) => false,
);
},
),
],
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,
),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: merchantsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Text('Failed to load merchants: $error'),
),
data: (merchants) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.wifi_off_outlined,
size: 56,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 12),
Text(
'Total (${merchants.length})',
style: Theme.of(context).textTheme.titleLarge,
'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'),
),
const SizedBox(height: 8),
Expanded(
child: RefreshIndicator(
onRefresh: () => ref.refresh(merchantListProvider.future),
child: merchants.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(height: 120),
Center(child: Text('No merchants found')),
],
),
),
)
: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
itemCount: merchants.length,
separatorBuilder: (_, __) =>
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 ?? '-'),
),
);
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
MerchantHeader(
loadedCount: pagingState.items.length,
query: query,
onQueryChanged: (value) {
ref.read(merchantSearchQueryProvider.notifier).state =
value;
pagingViewModel.setSearchTerm(value);
},
onClear: () {
ref.read(merchantSearchQueryProvider.notifier).state = '';
pagingViewModel.setSearchTerm('');
},
),
Expanded(
child: MerchantListView(
merchants: pagingState.items,
hasMore: pagingState.hasMore,
isLoadingMore: pagingState.isLoadingMore,
onRefresh: pagingViewModel.refresh,
onMerchantTap: (merchant) {
final id = merchant.id;
if (id == null) {
return;
}
_openTerminalSelection(context, id, merchant.name ?? '-');
},
onLoadMore: pagingViewModel.loadMore,
onEndReached: pagingViewModel.loadMore,
),
),
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')));
}
void _showProfile(BuildContext context) {
void _showProfile(BuildContext context, LoginUser user) {
showDialog<void>(
context: context,
builder: (context) {

View File

@ -1,6 +1,5 @@
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/domain/entities/merchant.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/presentation/auth/session_controller.dart';
@ -15,19 +14,10 @@ final merchantRepositoryProvider = Provider<MerchantRepository>((ref) {
);
});
final merchantListProvider = FutureProvider<List<Merchant>>((ref) async {
final sessionUser = ref.watch(sessionControllerProvider);
if (sessionUser == null) {
throw Exception('No active session');
}
return ref
.watch(merchantRepositoryProvider)
.getMerchants(token: sessionUser.token);
});
final terminalListProvider =
FutureProvider.family<List<Terminal>, String>((ref, merchantId) async {
final terminalListProvider = FutureProvider.family<List<Terminal>, String>((
ref,
merchantId,
) async {
final sessionUser = ref.watch(sessionControllerProvider);
if (sessionUser == null) {
throw Exception('No active session');

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,103 +1,195 @@
import 'package:e_receipt_mobile/presentation/components/rounded_input.dart';
import 'package:e_receipt_mobile/presentation/home/home_screen.dart';
import 'package:e_receipt_mobile/presentation/login/login_form_providers.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/widgets/login_background.dart';
import 'package:e_receipt_mobile/presentation/login/widgets/login_error_banner.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class LoginPage extends ConsumerStatefulWidget {
class LoginPage extends ConsumerWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
final _formKey = GlobalKey<FormState>();
final _usernameController = TextEditingController();
final _passwordController = TextEditingController();
@override
void dispose() {
_usernameController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(loginViewModelProvider);
ref.listen(loginViewModelProvider, (previous, next) {
if (!mounted) {
void _submit({
required WidgetRef ref,
required LoginState state,
required GlobalKey<FormState> formKey,
required TextEditingController usernameController,
required TextEditingController passwordController,
}) {
if (state.isLoading) {
return;
}
if (next.errorMessage != null &&
previous?.errorMessage != next.errorMessage) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(next.errorMessage!),
backgroundColor: Colors.red,
),
ref.read(loginViewModelProvider.notifier).clearMessages();
FocusManager.instance.primaryFocus?.unfocus();
ref.read(loginAttemptedSubmitProvider.notifier).state = true;
if (!(formKey.currentState?.validate() ?? false)) {
return;
}
TextInput.finishAutofillContext();
ref.read(loginViewModelProvider.notifier).login(
username: usernameController.text,
password: passwordController.text,
);
}
if (next.successMessage != null &&
next.user != null &&
previous?.successMessage != next.successMessage) {
Navigator.of(context).pushReplacement(
MaterialPageRoute<void>(
builder: (_) => HomeScreen(user: next.user!),
),
);
}
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(loginViewModelProvider);
final colorScheme = Theme.of(context).colorScheme;
final formKey = ref.watch(loginFormKeyProvider);
final usernameController = ref.watch(loginUsernameControllerProvider);
final passwordController = ref.watch(loginPasswordControllerProvider);
final usernameFocusNode = ref.watch(loginUsernameFocusNodeProvider);
final passwordFocusNode = ref.watch(loginPasswordFocusNodeProvider);
final obscurePassword = ref.watch(loginObscurePasswordProvider);
final attemptedSubmit = ref.watch(loginAttemptedSubmitProvider);
return Scaffold(
backgroundColor: Colors.white,
body: Center(
body: AnnotatedRegion<SystemUiOverlayStyle>(
value: Theme.of(context).brightness == Brightness.dark
? SystemUiOverlayStyle.light
: SystemUiOverlayStyle.dark,
child: LoginBackground(
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(32),
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 24,
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
constraints: const BoxConstraints(maxWidth: 440),
child: Card(
elevation: 8,
elevation: 0,
color: colorScheme.surface.withOpacity(
Theme.of(context).brightness == Brightness.dark
? 0.75
: 0.92,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
borderRadius: BorderRadius.circular(28),
side: BorderSide(
color: colorScheme.outlineVariant.withOpacity(0.35),
),
),
child: Padding(
padding: const EdgeInsets.all(20),
padding: const EdgeInsets.fromLTRB(20, 22, 20, 18),
child: AutofillGroup(
child: Form(
key: _formKey,
key: formKey,
autovalidateMode: attemptedSubmit
? AutovalidateMode.onUserInteraction
: AutovalidateMode.disabled,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Container(
height: 44,
width: 44,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
colorScheme.primary,
colorScheme.tertiary,
],
),
),
child: Icon(
Icons.receipt_long,
color: colorScheme.onPrimary,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
'E-Receipt',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall,
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(
fontWeight: FontWeight.w700,
),
const SizedBox(height: 20),
//Username
roundedInput(
controller: _usernameController,
hint: "Username",
icon: Icons.person,
),
Text(
'Sign in to continue',
style: Theme.of(context)
.textTheme
.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 SizedBox(height: 12),
//Password
roundedInput(
controller: _passwordController,
hint: "Password",
icon: Icons.lock,
isPassword: true,
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';
@ -107,34 +199,84 @@ class _LoginPageState extends ConsumerState<LoginPage> {
}
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,
),
const SizedBox(height: 20),
FilledButton(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(18),
),
suffixIcon: IconButton(
tooltip: obscurePassword
? 'Show password'
: 'Hide password',
onPressed: state.isLoading
? null
: () {
ref
.read(loginViewModelProvider.notifier)
.clearMessages();
if (_formKey.currentState?.validate() ??
false) {
ref
.read(loginViewModelProvider.notifier)
.login(
username: _usernameController.text,
password: _passwordController.text,
);
}
},
child: state.isLoading
: () => 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: 20,
width: 20,
height: 18,
width: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Text('Login'),
: 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,
),
),
],
),
@ -144,6 +286,11 @@ class _LoginPageState extends ConsumerState<LoginPage> {
),
),
),
),
),
),
),
);
}
}

View File

@ -1,5 +1,7 @@
import 'package:e_receipt_mobile/domain/entities/login_user.dart';
import 'package:flutter/foundation.dart';
@immutable
class LoginState {
const LoginState({
this.isLoading = false,
@ -13,15 +15,14 @@ class LoginState {
final String? successMessage;
final LoginUser? user;
static const Object _unset = Object();
LoginState copyWith({
bool? isLoading,
String? errorMessage,
String? successMessage,
Object? user = _unset,
LoginUser? user,
bool clearError = false,
bool clearSuccess = false,
bool clearUser = false,
}) {
return LoginState(
isLoading: isLoading ?? this.isLoading,
@ -29,7 +30,12 @@ class LoginState {
successMessage: clearSuccess
? null
: 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/network/api_key_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/data/repositories/api_auth_repository.dart';
@ -9,18 +10,28 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
final rawHttpClientProvider = Provider<http.Client>((ref) {
final client = LoggingHttpClient();
final client = ApiKeyHttpClient(
innerClient: LoggingHttpClient(),
apiSecret: AppConfig.apiSecret,
);
ref.onDispose(client.close);
return client;
});
final authenticatedHttpClientProvider = Provider<http.Client>((ref) {
final client = AuthHttpClient(
innerClient: ApiKeyHttpClient(
innerClient: LoggingHttpClient(),
apiSecret: AppConfig.apiSecret,
),
accessTokenProvider: () => ref.read(sessionControllerProvider)?.token,
shouldRefreshOnUnauthorized: (uri) =>
!uri.path.endsWith('/receipt/auth/login') &&
!uri.path.endsWith('/receipt/auth/refresh-token'),
shouldRefreshOnUnauthorized: (uri) {
final path = uri.path;
return !(path.endsWith('/auth/login') ||
path.endsWith('/auth/refresh-token') ||
path.endsWith('/receipt/auth/login') ||
path.endsWith('/receipt/auth/refresh-token'));
},
refreshAccessToken: () async {
final sessionUser = ref.read(sessionControllerProvider);
if (sessionUser == null) {
@ -28,7 +39,9 @@ final authenticatedHttpClientProvider = Provider<http.Client>((ref) {
}
try {
final refreshedUser = await ref.read(authRepositoryProvider).refreshSession(
final refreshedUser = await ref
.read(authRepositoryProvider)
.refreshSession(
username: sessionUser.username,
refreshToken: sessionUser.refreshToken,
role: sessionUser.role,
@ -68,7 +81,10 @@ class LoginViewModel extends StateNotifier<LoginState> {
final AuthRepository _authRepository;
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();
if (trimmedUsername.isEmpty || password.isEmpty) {
state = state.copyWith(
@ -83,7 +99,7 @@ class LoginViewModel extends StateNotifier<LoginState> {
isLoading: true,
clearError: true,
clearSuccess: true,
user: null,
clearUser: true,
);
_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,143 +26,544 @@ class _TerminalNextScreenState extends ConsumerState<TerminalNextScreen> {
Widget build(BuildContext context) {
final serial = widget.terminal.serial?.trim() ?? '';
final hasSerial = serial.isNotEmpty;
final query = TransactionQuery(serial: serial, range: _selectedRange);
final transactionsAsync = hasSerial
? ref.watch(
transactionListProvider(
TransactionQuery(serial: serial, range: _selectedRange),
),
)
? ref.watch(transactionListProvider(query))
: null;
final colorScheme = Theme.of(context).colorScheme;
final terminalName =
_sanitizeMultiline(widget.terminal.name) ?? widget.terminal.tid ?? '-';
return Scaffold(
appBar: AppBar(title: const Text('Terminal Transactions')),
body: Padding(
padding: const EdgeInsets.all(16),
appBar: AppBar(title: Text(terminalName)),
body: SafeArea(
child: RefreshIndicator.adaptive(
onRefresh: () async {
if (!hasSerial) {
return;
}
await ref.refresh(transactionListProvider(query).future);
},
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 10),
child: Card(
elevation: 0,
color: colorScheme.surfaceContainerLow,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(22),
side: BorderSide(
color: colorScheme.outlineVariant.withOpacity(0.55),
),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
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(
'Merchant: ${widget.merchantName}',
style: Theme.of(context).textTheme.titleMedium,
widget.merchantName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 8),
Text(
'Terminal: ${widget.terminal.name ?? widget.terminal.tid ?? '-'}',
terminalName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 4),
Text('TID: ${widget.terminal.tid ?? '-'}'),
const SizedBox(height: 4),
Text(
'MIDs: ${(widget.terminal.mids == null || widget.terminal.mids!.isEmpty) ? '-' : widget.terminal.mids!.join(', ')}',
),
const SizedBox(height: 4),
Text('Serial: ${widget.terminal.serial ?? '-'}'),
const SizedBox(height: 4),
Text('Address: ${widget.terminal.address ?? '-'}'),
const SizedBox(height: 16),
Row(
],
),
),
],
),
const SizedBox(height: 12),
_InfoRow(
label: 'Serial',
value: widget.terminal.serial,
),
_InfoRow(
label: 'Address',
value: _sanitizeMultiline(widget.terminal.address),
),
],
),
),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Row(
children: [
Text(
'Transactions',
style: Theme.of(context).textTheme.titleMedium,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w800),
),
const Spacer(),
const Text('Range: '),
const SizedBox(width: 8),
DropdownButton<String>(
value: _selectedRange,
items: _ranges
SegmentedButton<String>(
segments: _ranges
.map(
(range) => DropdownMenuItem<String>(
(range) => ButtonSegment<String>(
value: range,
child: Text(range),
label: Text(range.toUpperCase()),
),
)
.toList(),
onChanged: (value) {
if (value == null || value == _selectedRange) {
selected: <String>{_selectedRange},
onSelectionChanged: (value) {
final next = value.firstOrNull;
if (next == null || next == _selectedRange) {
return;
}
setState(() {
_selectedRange = value;
});
setState(() => _selectedRange = next);
},
style: ButtonStyle(
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
],
),
const SizedBox(height: 8),
Expanded(
child: !hasSerial
? const Center(
child: Text('Serial is required to load transactions'),
)
: transactionsAsync!.when(
loading: () =>
const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Text('Failed to load transactions: $error'),
),
),
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) {
return RefreshIndicator(
onRefresh: () => ref.refresh(
transactionListProvider(
TransactionQuery(
serial: serial,
range: _selectedRange,
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,
),
).future,
),
child: transactions.isEmpty
? ListView(
physics:
const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(height: 120),
Center(child: Text('No transactions found')),
],
)
: ListView.separated(
physics:
const AlwaysScrollableScrollPhysics(),
),
),
),
),
];
}
return [
SliverPadding(
padding: EdgeInsets.fromLTRB(
16,
0,
16,
MediaQuery.viewPaddingOf(context).bottom + 16,
),
sliver: SliverList.separated(
itemCount: transactions.length,
separatorBuilder: (_, __) =>
const SizedBox(height: 8),
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(
child: ListTile(
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,
builder: (_) => TransactionReceiptScreen(
merchantName: widget.merchantName,
terminal: widget.terminal,
transaction: item,
),
),
);
},
title: Text(
'Amount: ${item.amount?.toString() ?? '-'}',
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 12,
),
subtitle: Text(
'Status: ${item.status ?? '-'} | Type: ${item.type ?? '-'} | RRN: ${item.rrn ?? '-'}\nTap to view receipt detail',
child: Row(
children: [
Container(
height: 44,
width: 44,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
14,
),
trailing:
const Icon(Icons.receipt_long_outlined),
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(width: 12),
Expanded(
child: Text(
text,
textAlign: TextAlign.right,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
}
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/presentation/home/home_view_model.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_riverpod/flutter_riverpod.dart';
class TerminalSelectionScreen extends ConsumerStatefulWidget {
class TerminalSelectionScreen extends ConsumerWidget {
const TerminalSelectionScreen({
required this.merchantId,
required this.merchantName,
@ -15,58 +17,92 @@ class TerminalSelectionScreen extends ConsumerStatefulWidget {
final String merchantName;
@override
ConsumerState<TerminalSelectionScreen> createState() =>
_TerminalSelectionScreenState();
}
class _TerminalSelectionScreenState
extends ConsumerState<TerminalSelectionScreen> {
@override
Widget build(BuildContext context) {
final terminalsAsync = ref.watch(terminalListProvider(widget.merchantId));
Widget build(BuildContext context, WidgetRef ref) {
final terminalsAsync = ref.watch(terminalListProvider(merchantId));
final colorScheme = Theme.of(context).colorScheme;
return Scaffold(
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: SafeArea(
bottom: true,
child: terminalsAsync.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: [
Icon(
Icons.wifi_off_outlined,
size: 56,
color: colorScheme.onSurfaceVariant,
),
const SizedBox(height: 12),
Text(
'Failed to load terminals',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 6),
Text(
'$error',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 14),
FilledButton.tonal(
onPressed: () =>
ref.invalidate(terminalListProvider(merchantId)),
child: const Text('Try again'),
),
body: terminalsAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(child: Text('Failed to load terminals: $error')),
data: (terminals) {
return RefreshIndicator(
onRefresh: () =>
ref.refresh(terminalListProvider(widget.merchantId).future),
child: terminals.isEmpty
? ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
children: const [
SizedBox(height: 120),
Center(child: Text('No terminals found')),
],
)
: ListView.separated(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
itemCount: terminals.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final terminal = terminals[index];
return Card(
child: ListTile(
onTap: () => _openNextScreen(context, terminal),
leading: const Icon(Icons.point_of_sale),
trailing: const Icon(Icons.chevron_right),
title: Text(terminal.name ?? terminal.tid ?? '-'),
subtitle: Text(
'TID: ${terminal.tid ?? '-'} | MIDs: ${(terminal.mids == null || terminal.mids!.isEmpty) ? '-' : terminal.mids!.join(', ')}',
),
),
),
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) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => TerminalNextScreen(
merchantName: widget.merchantName,
terminal: terminal,
),
builder: (_) =>
TerminalNextScreen(merchantName: merchantName, 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/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_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({
required this.merchantName,
required this.terminal,
@ -15,130 +22,320 @@ class TransactionReceiptScreen extends StatelessWidget {
final TransactionRecord transaction;
@override
Widget build(BuildContext context) {
final raw = transaction.raw ?? const <String, dynamic>{};
final amount = _first(raw, const ['DE4']) ?? transaction.amount?.toString();
final currency = _first(raw, const ['DE49']) ?? 'MMK';
final status = _statusLabel(
_first(raw, const ['description', 'DE39', 'status']) ?? transaction.status,
Widget build(BuildContext context, WidgetRef ref) {
final transactionId = transaction.id?.trim();
if (transactionId == null || transactionId.isEmpty) {
return Scaffold(
appBar: AppBar(title: const Text('Receipt')),
body: const SafeArea(
child: Center(child: Text('Missing transaction id')),
),
);
}
final pdfAsync = ref.watch(
transactionReceiptViewDataProvider(
TransactionReceiptQuery(
transactionId: transactionId,
copyFor: 'Merchant',
),
),
);
return Scaffold(
appBar: AppBar(title: const Text('Receipt Detail')),
appBar: AppBar(
title: const Text('Receipt'),
actions: [
IconButton(
tooltip: 'Reload',
onPressed: () => ref.invalidate(
transactionReceiptViewDataProvider(
TransactionReceiptQuery(
transactionId: transactionId,
copyFor: 'Merchant',
),
),
),
icon: const Icon(Icons.refresh),
),
],
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Card(
bottom: true,
child: pdfAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: Padding(
padding: const EdgeInsets.all(16),
child: DefaultTextStyle(
style: Theme.of(context).textTheme.bodyMedium!,
padding: EdgeInsets.fromLTRB(
24,
24,
24,
MediaQuery.viewPaddingOf(context).bottom + 24,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Center(
child: Text(
'E-Receipt',
style: Theme.of(context).textTheme.titleLarge,
),
),
const SizedBox(height: 4),
Center(child: Text(merchantName)),
const Icon(Icons.picture_as_pdf_outlined, size: 56),
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',
'Failed to load receipt PDF',
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleSmall,
),
const SizedBox(height: 4),
const SizedBox(height: 6),
Text(
'${amount ?? '-'} $currency',
Error.safeToString(error),
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall,
style: Theme.of(context).textTheme.bodyMedium,
),
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: 14),
FilledButton.tonal(
onPressed: () => ref.invalidate(
transactionReceiptViewDataProvider(
TransactionReceiptQuery(
transactionId: transactionId,
copyFor: 'Merchant',
),
),
const SizedBox(height: 12),
const Text('--------------------------------'),
Center(
child: Text(
'Thank you',
style: Theme.of(context).textTheme.bodySmall,
),
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(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Expanded(child: Text(label)),
const SizedBox(width: 8),
Expanded(
child: Text(
value == null || value.trim().isEmpty ? '-' : value,
textAlign: TextAlign.right,
padding: EdgeInsets.fromLTRB(16, 16, 16, viewPadding.bottom + 16),
child: LayoutBuilder(
builder: (context, constraints) {
final double targetWidth =
receiptWidthDp.clamp(0, constraints.maxWidth).toDouble();
final double targetHeight =
maxHeight.clamp(0, constraints.maxHeight).toDouble();
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) {
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';
class _ReceiptHtmlWebView extends StatefulWidget {
const _ReceiptHtmlWebView({required this.html});
final String html;
@override
State<_ReceiptHtmlWebView> createState() => _ReceiptHtmlWebViewState();
}
if (normalized == 'E' || normalized == 'FAILED') {
return 'FAILED';
class _ReceiptHtmlWebViewState extends State<_ReceiptHtmlWebView> {
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));
}
return normalized.isEmpty ? '-' : normalized;
@override
Widget build(BuildContext context) {
return WebViewWidget(controller: _controller);
}
}
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
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.0"
version: "1.4.1"
clock:
dependency: transitive
description:
@ -33,6 +33,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@ -41,6 +49,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.1"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
@ -57,6 +73,22 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct main"
description: flutter
@ -70,6 +102,14 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: "direct main"
description:
@ -83,6 +123,22 @@ packages:
description: flutter
source: sdk
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:
dependency: "direct main"
description:
@ -131,22 +187,30 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.1.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.17"
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
version: "0.13.0"
meta:
dependency: transitive
description:
@ -155,6 +219,22 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@ -163,6 +243,78 @@ packages:
url: "https://pub.dev"
source: hosted
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:
dependency: transitive
description:
@ -228,10 +380,10 @@ packages:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev"
source: hosted
version: "0.7.7"
version: "0.7.10"
typed_data:
dependency: transitive
description:
@ -264,6 +416,54 @@ packages:
url: "https://pub.dev"
source: hosted
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:
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
flutter_riverpod: ^2.6.1
http: ^1.2.2
flutter_pdfview: ^1.4.0
path_provider: ^2.1.5
webview_flutter: ^4.0.0
dev_dependencies:
flutter_test: