Compare commits
No commits in common. "dev" and "main" have entirely different histories.
@ -1,6 +1,6 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<application
|
<application
|
||||||
android:label="CB Prestige QR"
|
android:label="CB Prestige QR"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
|||||||
@ -1,33 +1,5 @@
|
|||||||
package com.example.cb_prestige_qr
|
package com.example.cb_prestige_qr
|
||||||
|
|
||||||
import android.provider.Settings
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
class MainActivity : FlutterActivity()
|
||||||
import io.flutter.plugin.common.MethodChannel
|
|
||||||
|
|
||||||
class MainActivity : FlutterActivity() {
|
|
||||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
|
||||||
super.configureFlutterEngine(flutterEngine)
|
|
||||||
|
|
||||||
MethodChannel(
|
|
||||||
flutterEngine.dartExecutor.binaryMessenger,
|
|
||||||
"cb_prestige_qr/device"
|
|
||||||
).setMethodCallHandler { call, result ->
|
|
||||||
when (call.method) {
|
|
||||||
"getDeviceId" -> {
|
|
||||||
val deviceId = Settings.Secure.getString(
|
|
||||||
contentResolver,
|
|
||||||
Settings.Secure.ANDROID_ID
|
|
||||||
)
|
|
||||||
|
|
||||||
if (deviceId.isNullOrBlank()) {
|
|
||||||
result.error("device_id_unavailable", "Unable to retrieve device ID.", null)
|
|
||||||
} else {
|
|
||||||
result.success(deviceId)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else -> result.notImplemented()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,3 +0,0 @@
|
|||||||
description: This file stores settings for Dart & Flutter DevTools.
|
|
||||||
documentation: https://docs.flutter.dev/tools/devtools/extensions#configure-extension-enablement-states
|
|
||||||
extensions:
|
|
||||||
@ -1,42 +1,16 @@
|
|||||||
import Flutter
|
import Flutter
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
|
||||||
private let deviceChannelName = "cb_prestige_qr/device"
|
override func application(
|
||||||
|
_ application: UIApplication,
|
||||||
override func application(
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
_ application: UIApplication,
|
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||||
|
}
|
||||||
let channel = FlutterMethodChannel(
|
}
|
||||||
name: deviceChannelName,
|
|
||||||
binaryMessenger: engineBridge.binaryMessenger
|
|
||||||
)
|
|
||||||
channel.setMethodCallHandler { call, result in
|
|
||||||
switch call.method {
|
|
||||||
case "getDeviceId":
|
|
||||||
if let identifier = UIDevice.current.identifierForVendor?.uuidString,
|
|
||||||
!identifier.isEmpty {
|
|
||||||
result(identifier)
|
|
||||||
} else {
|
|
||||||
result(
|
|
||||||
FlutterError(
|
|
||||||
code: "device_id_unavailable",
|
|
||||||
message: "Unable to retrieve device ID.",
|
|
||||||
details: nil
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
result(FlutterMethodNotImplemented)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -14,10 +14,10 @@
|
|||||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>cb_prestige_qr</string>
|
<string>cb_prestige_qr</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
|
|||||||
@ -1,17 +0,0 @@
|
|||||||
import 'package:cb_prestige_qr/app/theme/app_theme.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/auth/presentation/views/login_view.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class MyApp extends StatelessWidget {
|
|
||||||
const MyApp({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return MaterialApp(
|
|
||||||
title: 'CB Prestige Banking',
|
|
||||||
debugShowCheckedModeBanner: false,
|
|
||||||
theme: AppTheme.dark(),
|
|
||||||
home: const LoginView(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
class AppTheme {
|
|
||||||
const AppTheme._();
|
|
||||||
|
|
||||||
static ThemeData dark() {
|
|
||||||
return ThemeData(
|
|
||||||
useMaterial3: true,
|
|
||||||
colorScheme: ColorScheme.fromSeed(
|
|
||||||
seedColor: const Color(0xff896e4b),
|
|
||||||
brightness: Brightness.dark,
|
|
||||||
),
|
|
||||||
scaffoldBackgroundColor: const Color(0xff25262b),
|
|
||||||
appBarTheme: const AppBarTheme(
|
|
||||||
backgroundColor: Color(0xff896e4b),
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
surfaceTintColor: Colors.transparent,
|
|
||||||
),
|
|
||||||
inputDecorationTheme: InputDecorationTheme(
|
|
||||||
filled: true,
|
|
||||||
fillColor: const Color(0xff2a2b31),
|
|
||||||
labelStyle: const TextStyle(color: Colors.white70),
|
|
||||||
hintStyle: const TextStyle(color: Colors.white38),
|
|
||||||
prefixIconColor: Colors.white70,
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(18),
|
|
||||||
borderSide: BorderSide.none,
|
|
||||||
),
|
|
||||||
enabledBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(18),
|
|
||||||
borderSide: BorderSide(color: Colors.white.withOpacity(0.06)),
|
|
||||||
),
|
|
||||||
focusedBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(18),
|
|
||||||
borderSide: const BorderSide(color: Color(0xff896e4b), width: 1.2),
|
|
||||||
),
|
|
||||||
errorBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(18),
|
|
||||||
borderSide: const BorderSide(color: Colors.redAccent),
|
|
||||||
),
|
|
||||||
focusedErrorBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(18),
|
|
||||||
borderSide: const BorderSide(color: Colors.redAccent),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,5 +0,0 @@
|
|||||||
class AppConfig {
|
|
||||||
const AppConfig._();
|
|
||||||
|
|
||||||
static const apiBaseUrl = 'http://192.168.100.41:3000';
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
import 'package:cb_prestige_qr/config.dart';
|
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
|
|
||||||
class ApiClient {
|
|
||||||
static Dio create() {
|
|
||||||
return Dio(
|
|
||||||
BaseOptions(
|
|
||||||
baseUrl: AppConfig.apiBaseUrl,
|
|
||||||
connectTimeout: const Duration(seconds: 15),
|
|
||||||
receiveTimeout: const Duration(seconds: 15),
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Accept': 'application/json',
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,7 +2,10 @@ import 'dart:ui';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class GlobalLoadingOverlay extends StatelessWidget {
|
class GlobalLoadingOverlay extends StatelessWidget {
|
||||||
const GlobalLoadingOverlay({super.key, required this.isLoading});
|
const GlobalLoadingOverlay({
|
||||||
|
super.key,
|
||||||
|
required this.isLoading,
|
||||||
|
});
|
||||||
|
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
|
|
||||||
@ -19,8 +22,12 @@ class GlobalLoadingOverlay extends StatelessWidget {
|
|||||||
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
|
filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12),
|
||||||
child: const SizedBox.expand(),
|
child: const SizedBox.expand(),
|
||||||
),
|
),
|
||||||
ColoredBox(color: Colors.black.withOpacity(0.55)),
|
ColoredBox(
|
||||||
const Center(child: _AnimatedLoader()),
|
color: Colors.black.withOpacity(0.55),
|
||||||
|
),
|
||||||
|
const Center(
|
||||||
|
child: _AnimatedLoader(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -52,15 +59,30 @@ class _AnimatedLoaderState extends State<_AnimatedLoader>
|
|||||||
|
|
||||||
/// Main fade (smooth breathing)
|
/// Main fade (smooth breathing)
|
||||||
_mainOpacity = TweenSequence<double>([
|
_mainOpacity = TweenSequence<double>([
|
||||||
TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.0), weight: 50),
|
TweenSequenceItem(
|
||||||
TweenSequenceItem(tween: Tween(begin: 1.0, end: 0.0), weight: 50),
|
tween: Tween(begin: 0.0, end: 1.0),
|
||||||
]).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
|
weight: 50,
|
||||||
|
),
|
||||||
|
TweenSequenceItem(
|
||||||
|
tween: Tween(begin: 1.0, end: 0.0),
|
||||||
|
weight: 50,
|
||||||
|
),
|
||||||
|
]).animate(
|
||||||
|
CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
),
|
||||||
|
);
|
||||||
/// Glow fade (slightly offset feel)
|
/// Glow fade (slightly offset feel)
|
||||||
_glowOpacity = Tween<double>(
|
_glowOpacity = Tween<double>(
|
||||||
begin: 0.2,
|
begin: 0.2,
|
||||||
end: 0.5,
|
end: 0.5,
|
||||||
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
|
).animate(
|
||||||
|
CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@ -1,15 +0,0 @@
|
|||||||
import 'package:cb_prestige_qr/core/network/api_client.dart';
|
|
||||||
import 'package:cb_prestige_qr/core/services/device_id_service.dart';
|
|
||||||
import 'package:cb_prestige_qr/core/storage/local_storage_service.dart';
|
|
||||||
import 'package:dio/dio.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
|
|
||||||
final localStorageServiceProvider = Provider<LocalStorageService>(
|
|
||||||
(ref) => LocalStorageService(),
|
|
||||||
);
|
|
||||||
|
|
||||||
final deviceIdServiceProvider = Provider<DeviceIdService>(
|
|
||||||
(ref) => DeviceIdService(),
|
|
||||||
);
|
|
||||||
|
|
||||||
final dioProvider = Provider<Dio>((ref) => ApiClient.create());
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
class DeviceIdService {
|
|
||||||
Future<String> getDeviceId() async {
|
|
||||||
// final deviceId = await const MethodChannel(
|
|
||||||
// 'cb_prestige_qr/device',
|
|
||||||
// ).invokeMethod<String>('getDeviceId');
|
|
||||||
// if (deviceId == null || deviceId.trim().isEmpty) {
|
|
||||||
// throw PlatformException(
|
|
||||||
// code: 'device_id_unavailable',
|
|
||||||
// message: 'Unable to retrieve device ID.',
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
return 'demo_device_id';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
class LocalStorageService {
|
|
||||||
Future<void> setBool(String key, bool value) async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.setBool(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool?> getBool(String key) async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
return prefs.getBool(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> setString(String key, String value) async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.setString(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<String?> getString(String key) async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
return prefs.getString(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> remove(String key) async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
await prefs.remove(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,18 +1,18 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:cb_prestige_qr/core/utils/ScanShell.dart';
|
||||||
import 'package:cb_prestige_qr/core/presentation/widgets/global_loading_overlay.dart';
|
import 'package:cb_prestige_qr/core/presentation/widgets/global_loading_overlay.dart';
|
||||||
import 'package:cb_prestige_qr/core/widgets/center_nav_button.dart';
|
import 'package:cb_prestige_qr/core/widgets/CenterNavButton.dart';
|
||||||
import 'package:cb_prestige_qr/core/widgets/nav_item.dart';
|
import 'package:cb_prestige_qr/core/widgets/NavItem.dart';
|
||||||
import 'package:cb_prestige_qr/features/analysis/presentation/views/analysis_view.dart';
|
import 'package:cb_prestige_qr/features/analysis/presentation/pages/analysis_page.dart';
|
||||||
import 'package:cb_prestige_qr/features/history/presentation/views/history_view.dart';
|
import 'package:cb_prestige_qr/features/history/presentation/pages/history_page.dart';
|
||||||
import 'package:cb_prestige_qr/features/home/presentation/views/home_view.dart';
|
import 'package:cb_prestige_qr/features/home/presentation/pages/home.dart';
|
||||||
import 'package:cb_prestige_qr/features/scan/presentation/views/scan_flow.dart';
|
import 'package:cb_prestige_qr/features/settings/presentation/pages/settings_page.dart';
|
||||||
import 'package:cb_prestige_qr/features/settings/presentation/views/settings_view.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
final navIndexProvider = NotifierProvider<NavIndexNotifier, int>(
|
final navIndexNotifierProvider = NotifierProvider<NavIndexNotifier, int>(
|
||||||
NavIndexNotifier.new,
|
NavIndexNotifier.new,
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -39,9 +39,7 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
|
|
||||||
void _startLoading([Duration duration = const Duration(seconds: 1)]) {
|
void _startLoading([Duration duration = const Duration(seconds: 1)]) {
|
||||||
_timer?.cancel();
|
_timer?.cancel();
|
||||||
if (!_isLoading) {
|
if (!_isLoading) setState(() => _isLoading = true);
|
||||||
setState(() => _isLoading = true);
|
|
||||||
}
|
|
||||||
_timer = Timer(duration, () {
|
_timer = Timer(duration, () {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _isLoading = false);
|
setState(() => _isLoading = false);
|
||||||
@ -51,10 +49,8 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
final initialIndex = ref.read(navIndexProvider);
|
final initialIndex = ref.read(navIndexNotifierProvider);
|
||||||
if (initialIndex != 0) {
|
if (initialIndex != 0) _startLoading();
|
||||||
_startLoading();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -65,20 +61,21 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final selectedIndex = ref.watch(navIndexProvider);
|
final selectedIndex = ref.watch(navIndexNotifierProvider);
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
ref.listen<int>(navIndexProvider, (previous, next) {
|
ref.listen<int>(navIndexNotifierProvider, (previous, next) {
|
||||||
if (previous == null || previous == next || next == 0) return;
|
if (previous == null || previous == next) return;
|
||||||
|
if (next == 0) return;
|
||||||
_startLoading();
|
_startLoading();
|
||||||
});
|
});
|
||||||
|
|
||||||
const pages = <Widget>[
|
final pages = [
|
||||||
HomeView(),
|
const HomeView(),
|
||||||
AnalysisView(),
|
const AnalysisPage(),
|
||||||
SizedBox(),
|
const SizedBox(),
|
||||||
HistoryView(),
|
const HistoryPage(),
|
||||||
SettingsView(),
|
const SettingsPage(),
|
||||||
];
|
];
|
||||||
|
|
||||||
return Stack(
|
return Stack(
|
||||||
@ -116,16 +113,18 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
child: NavItem(
|
child: NavItem(
|
||||||
icon: Icons.home_rounded,
|
icon: Icons.home_rounded,
|
||||||
isActive: selectedIndex == 0,
|
isActive: selectedIndex == 0,
|
||||||
onTap: () =>
|
onTap: () => ref
|
||||||
ref.read(navIndexProvider.notifier).setIndex(0),
|
.read(navIndexNotifierProvider.notifier)
|
||||||
|
.setIndex(0),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: NavItem(
|
child: NavItem(
|
||||||
icon: Icons.analytics,
|
icon: Icons.analytics,
|
||||||
isActive: selectedIndex == 1,
|
isActive: selectedIndex == 1,
|
||||||
onTap: () =>
|
onTap: () => ref
|
||||||
ref.read(navIndexProvider.notifier).setIndex(1),
|
.read(navIndexNotifierProvider.notifier)
|
||||||
|
.setIndex(1),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
@ -145,16 +144,18 @@ class _MainShellState extends ConsumerState<MainShell> {
|
|||||||
child: NavItem(
|
child: NavItem(
|
||||||
icon: Icons.history_rounded,
|
icon: Icons.history_rounded,
|
||||||
isActive: selectedIndex == 3,
|
isActive: selectedIndex == 3,
|
||||||
onTap: () =>
|
onTap: () => ref
|
||||||
ref.read(navIndexProvider.notifier).setIndex(3),
|
.read(navIndexNotifierProvider.notifier)
|
||||||
|
.setIndex(3),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: NavItem(
|
child: NavItem(
|
||||||
icon: Icons.person_rounded,
|
icon: Icons.person_rounded,
|
||||||
isActive: selectedIndex == 4,
|
isActive: selectedIndex == 4,
|
||||||
onTap: () =>
|
onTap: () => ref
|
||||||
ref.read(navIndexProvider.notifier).setIndex(4),
|
.read(navIndexNotifierProvider.notifier)
|
||||||
|
.setIndex(4),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import 'package:cb_prestige_qr/features/scan/presentation/views/scan_page.dart';
|
import 'package:cb_prestige_qr/features/scan/presentation/pages/scan_page.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class ScanFlow extends StatelessWidget {
|
class ScanFlow extends StatelessWidget {
|
||||||
@ -1,19 +1,19 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class CenterNavButton extends StatelessWidget {
|
class CenterNavButton extends StatelessWidget {
|
||||||
const CenterNavButton({
|
final bool isActive;
|
||||||
super.key,
|
final VoidCallback onTap;
|
||||||
required this.isActive,
|
|
||||||
required this.onTap,
|
const CenterNavButton({
|
||||||
});
|
super.key,
|
||||||
|
required this.isActive,
|
||||||
final bool isActive;
|
required this.onTap,
|
||||||
final VoidCallback onTap;
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
@ -27,13 +27,13 @@ class CenterNavButton extends StatelessWidget {
|
|||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: AnimatedScale(
|
child: AnimatedScale(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
scale: isActive ? 1.15 : 1.0,
|
scale: isActive ? 1.15 : 1.0,
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 48,
|
height: 48,
|
||||||
width: 48,
|
width: 48,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
@ -50,15 +50,15 @@ class CenterNavButton extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.qr_code_scanner,
|
Icons.qr_code_scanner,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
size: 24,
|
size: 24,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,6 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class NavItem extends StatelessWidget {
|
class NavItem extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final bool isActive;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
const NavItem({
|
const NavItem({
|
||||||
super.key,
|
super.key,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
@ -8,10 +12,6 @@ class NavItem extends StatelessWidget {
|
|||||||
required this.onTap,
|
required this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
final IconData icon;
|
|
||||||
final bool isActive;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@ -29,20 +29,20 @@ class NavItem extends StatelessWidget {
|
|||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: AnimatedScale(
|
child: AnimatedScale(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
scale: isActive ? 1.2 : 1.0,
|
scale: isActive ? 1.2 : 1.0,
|
||||||
child: Icon(
|
child: Icon(
|
||||||
icon,
|
icon,
|
||||||
size: 26,
|
size: 26,
|
||||||
color: isActive
|
color: isActive
|
||||||
? theme.colorScheme.primary
|
? theme.colorScheme.primary
|
||||||
: theme.colorScheme.onSurfaceVariant,
|
: theme.colorScheme.onSurfaceVariant,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2,7 +2,7 @@ enum AnalysisChartMetric { scans, pointsUsed }
|
|||||||
|
|
||||||
extension AnalysisChartMetricX on AnalysisChartMetric {
|
extension AnalysisChartMetricX on AnalysisChartMetric {
|
||||||
String get label => switch (this) {
|
String get label => switch (this) {
|
||||||
AnalysisChartMetric.scans => 'Member scans',
|
AnalysisChartMetric.scans => 'Scans',
|
||||||
AnalysisChartMetric.pointsUsed => 'Points redeemed',
|
AnalysisChartMetric.pointsUsed => 'Points Used',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -40,7 +40,7 @@ class AnalysisSummaryUiModel {
|
|||||||
|
|
||||||
@immutable
|
@immutable
|
||||||
class AnalysisUiState {
|
class AnalysisUiState {
|
||||||
const AnalysisUiState({
|
AnalysisUiState({
|
||||||
int? rangeDays,
|
int? rangeDays,
|
||||||
String? rangeLabel,
|
String? rangeLabel,
|
||||||
AnalysisSummaryUiModel? summary,
|
AnalysisSummaryUiModel? summary,
|
||||||
@ -62,6 +62,7 @@ class AnalysisUiState {
|
|||||||
final String? rangeLabel;
|
final String? rangeLabel;
|
||||||
final AnalysisSummaryUiModel? summary;
|
final AnalysisSummaryUiModel? summary;
|
||||||
final List<AnalysisSeriesPointUiModel>? series;
|
final List<AnalysisSeriesPointUiModel>? series;
|
||||||
|
|
||||||
final double? averageScansPerDay;
|
final double? averageScansPerDay;
|
||||||
final double? averagePointsPerDay;
|
final double? averagePointsPerDay;
|
||||||
final double? pointsPerScan;
|
final double? pointsPerScan;
|
||||||
@ -9,12 +9,12 @@ import 'analysis_chart_metric.dart';
|
|||||||
import 'analysis_range.dart';
|
import 'analysis_range.dart';
|
||||||
import 'analysis_ui_state.dart';
|
import 'analysis_ui_state.dart';
|
||||||
|
|
||||||
final analysisRangeProvider =
|
final analysisRangeNotifierProvider =
|
||||||
NotifierProvider<AnalysisRangeNotifier, AnalysisRangePreset>(
|
NotifierProvider<AnalysisRangeNotifier, AnalysisRangePreset>(
|
||||||
AnalysisRangeNotifier.new,
|
AnalysisRangeNotifier.new,
|
||||||
);
|
);
|
||||||
|
|
||||||
final analysisChartMetricProvider =
|
final analysisChartMetricNotifierProvider =
|
||||||
NotifierProvider<AnalysisChartMetricNotifier, AnalysisChartMetric>(
|
NotifierProvider<AnalysisChartMetricNotifier, AnalysisChartMetric>(
|
||||||
AnalysisChartMetricNotifier.new,
|
AnalysisChartMetricNotifier.new,
|
||||||
);
|
);
|
||||||
@ -53,7 +53,7 @@ final analysisViewModelProvider =
|
|||||||
class AnalysisViewModel extends AsyncNotifier<AnalysisUiState> {
|
class AnalysisViewModel extends AsyncNotifier<AnalysisUiState> {
|
||||||
@override
|
@override
|
||||||
Future<AnalysisUiState> build() async {
|
Future<AnalysisUiState> build() async {
|
||||||
final rangePreset = ref.watch(analysisRangeProvider);
|
final rangePreset = ref.watch(analysisRangeNotifierProvider);
|
||||||
final content = await ref.watch(_getAnalysisContentProvider)(
|
final content = await ref.watch(_getAnalysisContentProvider)(
|
||||||
rangeDays: rangePreset.days,
|
rangeDays: rangePreset.days,
|
||||||
);
|
);
|
||||||
578
lib/features/analysis/presentation/pages/analysis_page.dart
Normal file
578
lib/features/analysis/presentation/pages/analysis_page.dart
Normal file
@ -0,0 +1,578 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import '../manager/analysis_range.dart';
|
||||||
|
import '../manager/analysis_chart_metric.dart';
|
||||||
|
import '../manager/analysis_ui_state.dart';
|
||||||
|
import '../manager/analysis_view_model.dart';
|
||||||
|
import '../widgets/analysis_line_chart.dart';
|
||||||
|
|
||||||
|
class AnalysisPage extends ConsumerWidget {
|
||||||
|
const AnalysisPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final rangePreset = ref.watch(analysisRangeNotifierProvider);
|
||||||
|
final chartMetric = ref.watch(analysisChartMetricNotifierProvider);
|
||||||
|
final stateAsync = ref.watch(analysisViewModelProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: theme.scaffoldBackgroundColor,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Analysis'),
|
||||||
|
backgroundColor: theme.scaffoldBackgroundColor,
|
||||||
|
surfaceTintColor: theme.scaffoldBackgroundColor,
|
||||||
|
elevation: 0,
|
||||||
|
scrolledUnderElevation: 0,
|
||||||
|
),
|
||||||
|
body: RefreshIndicator(
|
||||||
|
onRefresh: () async => ref.invalidate(analysisViewModelProvider),
|
||||||
|
child: ListView(
|
||||||
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||||
|
children: [
|
||||||
|
_RangeSelector(
|
||||||
|
selected: rangePreset,
|
||||||
|
onSelected: (preset) => ref
|
||||||
|
.read(analysisRangeNotifierProvider.notifier)
|
||||||
|
.setRange(preset),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_ChartMetricSelector(
|
||||||
|
selected: chartMetric,
|
||||||
|
onSelected: (metric) => ref
|
||||||
|
.read(analysisChartMetricNotifierProvider.notifier)
|
||||||
|
.setMetric(metric),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
stateAsync.when(
|
||||||
|
data: (state) =>
|
||||||
|
_AnalysisBody(state: state, chartMetric: chartMetric),
|
||||||
|
loading: () => const _LoadingBody(),
|
||||||
|
error: (error, stackTrace) => _ErrorBody(
|
||||||
|
onRetry: () => ref.invalidate(analysisViewModelProvider),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RangeSelector extends StatelessWidget {
|
||||||
|
const _RangeSelector({required this.selected, required this.onSelected});
|
||||||
|
|
||||||
|
final AnalysisRangePreset selected;
|
||||||
|
final ValueChanged<AnalysisRangePreset> onSelected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SegmentedButton<AnalysisRangePreset>(
|
||||||
|
segments: AnalysisRangePreset.values
|
||||||
|
.map(
|
||||||
|
(preset) => ButtonSegment(value: preset, label: Text(preset.label)),
|
||||||
|
)
|
||||||
|
.toList(growable: false),
|
||||||
|
selected: <AnalysisRangePreset>{selected},
|
||||||
|
onSelectionChanged: (selection) {
|
||||||
|
if (selection.isEmpty) return;
|
||||||
|
onSelected(selection.first);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AnalysisBody extends StatelessWidget {
|
||||||
|
const _AnalysisBody({required this.state, required this.chartMetric});
|
||||||
|
|
||||||
|
final AnalysisUiState state;
|
||||||
|
final AnalysisChartMetric chartMetric;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final summary = state.summary ?? AnalysisSummaryUiModel.empty;
|
||||||
|
final series = state.series ?? const <AnalysisSeriesPointUiModel>[];
|
||||||
|
final rangeLabel = state.rangeLabel ?? '';
|
||||||
|
|
||||||
|
final values = series
|
||||||
|
.map(
|
||||||
|
(p) => switch (chartMetric) {
|
||||||
|
AnalysisChartMetric.scans => p.scanCount.toDouble(),
|
||||||
|
AnalysisChartMetric.pointsUsed => p.pointsUsed.toDouble(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
|
final totalValue = switch (chartMetric) {
|
||||||
|
AnalysisChartMetric.scans => summary.scans,
|
||||||
|
AnalysisChartMetric.pointsUsed => summary.pointsUsed,
|
||||||
|
};
|
||||||
|
final xLabels = series.map((p) => p.label).toList(growable: false);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_ChartCard(
|
||||||
|
title: chartMetric.label,
|
||||||
|
subtitle: rangeLabel,
|
||||||
|
totalValue: totalValue,
|
||||||
|
chart: AnalysisLineChart(values: values),
|
||||||
|
xLabels: xLabels,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_InsightRow(state: state),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_KpiGrid(summary: summary),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChartCard extends StatelessWidget {
|
||||||
|
const _ChartCard({
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.totalValue,
|
||||||
|
required this.chart,
|
||||||
|
required this.xLabels,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final int totalValue;
|
||||||
|
final Widget chart;
|
||||||
|
final List<String> xLabels;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.5)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.06),
|
||||||
|
blurRadius: 14,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
totalValue.toString(),
|
||||||
|
style: textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
const SizedBox.shrink(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
chart,
|
||||||
|
if (xLabels.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
_XAxisLabels(labels: xLabels),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _KpiGrid extends StatelessWidget {
|
||||||
|
const _KpiGrid({required this.summary});
|
||||||
|
|
||||||
|
final AnalysisSummaryUiModel summary;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GridView.count(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
childAspectRatio: 1.5,
|
||||||
|
children: [
|
||||||
|
_KpiCard(
|
||||||
|
title: 'Scans',
|
||||||
|
value: summary.scans.toString(),
|
||||||
|
icon: Icons.qr_code_scanner_rounded,
|
||||||
|
),
|
||||||
|
_KpiCard(
|
||||||
|
title: 'Users',
|
||||||
|
value: summary.users.toString(),
|
||||||
|
icon: Icons.people_alt_rounded,
|
||||||
|
),
|
||||||
|
_KpiCard(
|
||||||
|
title: 'Active Users',
|
||||||
|
value: summary.activeUsers.toString(),
|
||||||
|
icon: Icons.person_pin_circle_rounded,
|
||||||
|
),
|
||||||
|
_KpiCard(
|
||||||
|
title: 'Points Used',
|
||||||
|
value: summary.pointsUsed.toString(),
|
||||||
|
icon: Icons.stars_rounded,
|
||||||
|
),
|
||||||
|
_KpiCard(
|
||||||
|
title: 'Points/User',
|
||||||
|
value: summary.users == 0
|
||||||
|
? '0'
|
||||||
|
: (summary.pointsUsed / summary.users).toStringAsFixed(2),
|
||||||
|
icon: Icons.functions_rounded,
|
||||||
|
),
|
||||||
|
_KpiCard(
|
||||||
|
title: 'Success Rate',
|
||||||
|
value: '${summary.successRatePercent}%',
|
||||||
|
icon: Icons.verified_rounded,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ChartMetricSelector extends StatelessWidget {
|
||||||
|
const _ChartMetricSelector({
|
||||||
|
required this.selected,
|
||||||
|
required this.onSelected,
|
||||||
|
});
|
||||||
|
|
||||||
|
final AnalysisChartMetric selected;
|
||||||
|
final ValueChanged<AnalysisChartMetric> onSelected;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SegmentedButton<AnalysisChartMetric>(
|
||||||
|
segments: AnalysisChartMetric.values
|
||||||
|
.map(
|
||||||
|
(metric) => ButtonSegment(value: metric, label: Text(metric.label)),
|
||||||
|
)
|
||||||
|
.toList(growable: false),
|
||||||
|
selected: <AnalysisChartMetric>{selected},
|
||||||
|
onSelectionChanged: (selection) {
|
||||||
|
if (selection.isEmpty) return;
|
||||||
|
onSelected(selection.first);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _XAxisLabels extends StatelessWidget {
|
||||||
|
const _XAxisLabels({required this.labels});
|
||||||
|
|
||||||
|
final List<String> labels;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final textStyle = Theme.of(context).textTheme.labelSmall;
|
||||||
|
final onSurfaceVariant = Theme.of(context).colorScheme.onSurfaceVariant;
|
||||||
|
|
||||||
|
final count = labels.length;
|
||||||
|
final desiredTicks = 4;
|
||||||
|
final step = count <= desiredTicks ? 1 : (count - 1) ~/ (desiredTicks - 1);
|
||||||
|
|
||||||
|
final ticks = <int>{0, count - 1};
|
||||||
|
for (var i = 0; i < count; i += step) {
|
||||||
|
ticks.add(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: List.generate(count, (i) {
|
||||||
|
final show = ticks.contains(i);
|
||||||
|
return Expanded(
|
||||||
|
child: Align(
|
||||||
|
alignment: i == 0
|
||||||
|
? Alignment.centerLeft
|
||||||
|
: i == count - 1
|
||||||
|
? Alignment.centerRight
|
||||||
|
: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
show ? labels[i] : '',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: textStyle?.copyWith(color: onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InsightRow extends StatelessWidget {
|
||||||
|
const _InsightRow({required this.state});
|
||||||
|
|
||||||
|
final AnalysisUiState state;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
final averageScansPerDay = state.averageScansPerDay ?? 0;
|
||||||
|
final averagePointsPerDay = state.averagePointsPerDay ?? 0;
|
||||||
|
final pointsPerScan = state.pointsPerScan ?? 0;
|
||||||
|
final activeUserRatePercent = state.activeUserRatePercent ?? 0;
|
||||||
|
|
||||||
|
return Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 12,
|
||||||
|
children:
|
||||||
|
[
|
||||||
|
_InsightCard(
|
||||||
|
title: 'Avg scans/day',
|
||||||
|
value: averageScansPerDay.toStringAsFixed(1),
|
||||||
|
icon: Icons.stacked_line_chart_rounded,
|
||||||
|
),
|
||||||
|
_InsightCard(
|
||||||
|
title: 'Avg points/day',
|
||||||
|
value: averagePointsPerDay.toStringAsFixed(1),
|
||||||
|
icon: Icons.trending_up_rounded,
|
||||||
|
),
|
||||||
|
_InsightCard(
|
||||||
|
title: 'Points/scan',
|
||||||
|
value: pointsPerScan.toStringAsFixed(2),
|
||||||
|
icon: Icons.bolt_rounded,
|
||||||
|
),
|
||||||
|
_InsightCard(
|
||||||
|
title: 'Active rate',
|
||||||
|
value: '$activeUserRatePercent%',
|
||||||
|
icon: Icons.percent_rounded,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.map((w) {
|
||||||
|
return SizedBox(
|
||||||
|
width: (MediaQuery.of(context).size.width - 16 * 2 - 12) / 2,
|
||||||
|
child: w,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toList(growable: false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InsightCard extends StatelessWidget {
|
||||||
|
const _InsightCard({
|
||||||
|
required this.title,
|
||||||
|
required this.value,
|
||||||
|
required this.icon,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final String value;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.5)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.06),
|
||||||
|
blurRadius: 14,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 14, 14, 14),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primaryContainer,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
color: colorScheme.onPrimaryContainer,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _KpiCard extends StatelessWidget {
|
||||||
|
const _KpiCard({
|
||||||
|
required this.title,
|
||||||
|
required this.value,
|
||||||
|
required this.icon,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final String value;
|
||||||
|
final IconData icon;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.5)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.06),
|
||||||
|
blurRadius: 14,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(14, 14, 14, 14),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.primaryContainer,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
color: colorScheme.onPrimaryContainer,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
value,
|
||||||
|
style: textTheme.titleLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: textTheme.bodySmall?.copyWith(
|
||||||
|
color: colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LoadingBody extends StatelessWidget {
|
||||||
|
const _LoadingBody();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 40),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: const [
|
||||||
|
CircularProgressIndicator(),
|
||||||
|
SizedBox(height: 12),
|
||||||
|
Text('Loading analytics...'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ErrorBody extends StatelessWidget {
|
||||||
|
const _ErrorBody({required this.onRetry});
|
||||||
|
|
||||||
|
final VoidCallback onRetry;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 40),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Text('Unable to load analytics'),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
TextButton(onPressed: onRetry, child: const Text('Retry')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,836 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
|
|
||||||
import '../viewmodels/analysis_chart_metric.dart';
|
|
||||||
import '../viewmodels/analysis_range.dart';
|
|
||||||
import '../viewmodels/analysis_ui_state.dart';
|
|
||||||
import '../viewmodels/analysis_view_model.dart';
|
|
||||||
import '../widgets/analysis_line_chart.dart';
|
|
||||||
|
|
||||||
class AnalysisView extends ConsumerWidget {
|
|
||||||
const AnalysisView({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final rangePreset = ref.watch(analysisRangeProvider);
|
|
||||||
final chartMetric = ref.watch(analysisChartMetricProvider);
|
|
||||||
final stateAsync = ref.watch(analysisViewModelProvider);
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: theme.scaffoldBackgroundColor,
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('Cashier Analysis'),
|
|
||||||
backgroundColor: theme.scaffoldBackgroundColor,
|
|
||||||
surfaceTintColor: theme.scaffoldBackgroundColor,
|
|
||||||
elevation: 0,
|
|
||||||
scrolledUnderElevation: 0,
|
|
||||||
),
|
|
||||||
body: RefreshIndicator(
|
|
||||||
onRefresh: () async => ref.invalidate(analysisViewModelProvider),
|
|
||||||
child: ListView(
|
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 96),
|
|
||||||
children: [
|
|
||||||
_RangeSelector(
|
|
||||||
selected: rangePreset,
|
|
||||||
onSelected: (preset) =>
|
|
||||||
ref.read(analysisRangeProvider.notifier).setRange(preset),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
stateAsync.when(
|
|
||||||
data: (state) => _AnalysisBody(
|
|
||||||
state: state,
|
|
||||||
chartMetric: chartMetric,
|
|
||||||
onMetricSelected: (metric) => ref
|
|
||||||
.read(analysisChartMetricProvider.notifier)
|
|
||||||
.setMetric(metric),
|
|
||||||
),
|
|
||||||
loading: () => const _LoadingBody(),
|
|
||||||
error: (error, stackTrace) => _ErrorBody(
|
|
||||||
onRetry: () => ref.invalidate(analysisViewModelProvider),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RangeSelector extends StatelessWidget {
|
|
||||||
const _RangeSelector({required this.selected, required this.onSelected});
|
|
||||||
|
|
||||||
final AnalysisRangePreset selected;
|
|
||||||
final ValueChanged<AnalysisRangePreset> onSelected;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return SegmentedButton<AnalysisRangePreset>(
|
|
||||||
showSelectedIcon: false,
|
|
||||||
segments: AnalysisRangePreset.values
|
|
||||||
.map(
|
|
||||||
(preset) => ButtonSegment(value: preset, label: Text(preset.label)),
|
|
||||||
)
|
|
||||||
.toList(growable: false),
|
|
||||||
selected: <AnalysisRangePreset>{selected},
|
|
||||||
onSelectionChanged: (selection) {
|
|
||||||
if (selection.isEmpty) return;
|
|
||||||
onSelected(selection.first);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AnalysisBody extends StatelessWidget {
|
|
||||||
const _AnalysisBody({
|
|
||||||
required this.state,
|
|
||||||
required this.chartMetric,
|
|
||||||
required this.onMetricSelected,
|
|
||||||
});
|
|
||||||
|
|
||||||
final AnalysisUiState state;
|
|
||||||
final AnalysisChartMetric chartMetric;
|
|
||||||
final ValueChanged<AnalysisChartMetric> onMetricSelected;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final summary = state.summary ?? AnalysisSummaryUiModel.empty;
|
|
||||||
final series = state.series ?? const <AnalysisSeriesPointUiModel>[];
|
|
||||||
final values = series
|
|
||||||
.map(
|
|
||||||
(p) => switch (chartMetric) {
|
|
||||||
AnalysisChartMetric.scans => p.scanCount.toDouble(),
|
|
||||||
AnalysisChartMetric.pointsUsed => p.pointsUsed.toDouble(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.toList(growable: false);
|
|
||||||
final totalValue = switch (chartMetric) {
|
|
||||||
AnalysisChartMetric.scans => summary.scans,
|
|
||||||
AnalysisChartMetric.pointsUsed => summary.pointsUsed,
|
|
||||||
};
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
_ShiftSummaryCard(state: state),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_CashierActionGrid(summary: summary, state: state),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_ChartCard(
|
|
||||||
selectedMetric: chartMetric,
|
|
||||||
onMetricSelected: onMetricSelected,
|
|
||||||
totalValue: totalValue,
|
|
||||||
rangeLabel: state.rangeLabel ?? '',
|
|
||||||
chart: AnalysisLineChart(values: values, height: 132),
|
|
||||||
xLabels: series.map((p) => p.label).toList(growable: false),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_OperationalNotes(state: state),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ShiftSummaryCard extends StatelessWidget {
|
|
||||||
const _ShiftSummaryCard({required this.state});
|
|
||||||
|
|
||||||
final AnalysisUiState state;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
final textTheme = Theme.of(context).textTheme;
|
|
||||||
final summary = state.summary ?? AnalysisSummaryUiModel.empty;
|
|
||||||
final pointsPerScan = state.pointsPerScan ?? 0;
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
decoration: _cardDecoration(context),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 44,
|
|
||||||
height: 44,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.primaryContainer,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Icon(
|
|
||||||
Icons.point_of_sale_rounded,
|
|
||||||
color: colorScheme.onPrimaryContainer,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Loyalty counter',
|
|
||||||
style: textTheme.labelLarge?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
state.rangeLabel ?? 'Selected range',
|
|
||||||
style: textTheme.titleLarge?.copyWith(
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
_StatusPill(
|
|
||||||
label: '${summary.successRatePercent}% success',
|
|
||||||
icon: Icons.verified_rounded,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: _HeroMetric(
|
|
||||||
label: 'Member scans',
|
|
||||||
value: summary.scans.toString(),
|
|
||||||
icon: Icons.qr_code_scanner_rounded,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: _HeroMetric(
|
|
||||||
label: 'Points redeemed',
|
|
||||||
value: summary.pointsUsed.toString(),
|
|
||||||
icon: Icons.redeem_rounded,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_InlineStatBar(
|
|
||||||
items: [
|
|
||||||
_InlineStatItem(
|
|
||||||
label: 'Active members',
|
|
||||||
value: summary.activeUsers.toString(),
|
|
||||||
),
|
|
||||||
_InlineStatItem(
|
|
||||||
label: 'Coins/scan',
|
|
||||||
value: pointsPerScan.toStringAsFixed(1),
|
|
||||||
),
|
|
||||||
_InlineStatItem(
|
|
||||||
label: 'Range users',
|
|
||||||
value: summary.users.toString(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CashierActionGrid extends StatelessWidget {
|
|
||||||
const _CashierActionGrid({required this.summary, required this.state});
|
|
||||||
|
|
||||||
final AnalysisSummaryUiModel summary;
|
|
||||||
final AnalysisUiState state;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final averageScansPerDay = state.averageScansPerDay ?? 0;
|
|
||||||
final averagePointsPerDay = state.averagePointsPerDay ?? 0;
|
|
||||||
final activeUserRatePercent = state.activeUserRatePercent ?? 0;
|
|
||||||
|
|
||||||
return LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final tileWidth = (constraints.maxWidth - 10) / 2;
|
|
||||||
|
|
||||||
return Wrap(
|
|
||||||
spacing: 10,
|
|
||||||
runSpacing: 10,
|
|
||||||
children: [
|
|
||||||
_ActionMetricTile(
|
|
||||||
width: tileWidth,
|
|
||||||
title: 'Avg scans/day',
|
|
||||||
value: averageScansPerDay.toStringAsFixed(1),
|
|
||||||
icon: Icons.timeline_rounded,
|
|
||||||
),
|
|
||||||
_ActionMetricTile(
|
|
||||||
width: tileWidth,
|
|
||||||
title: 'Avg coins/day',
|
|
||||||
value: averagePointsPerDay.toStringAsFixed(1),
|
|
||||||
icon: Icons.savings_rounded,
|
|
||||||
),
|
|
||||||
_ActionMetricTile(
|
|
||||||
width: tileWidth,
|
|
||||||
title: 'Active rate',
|
|
||||||
value: '$activeUserRatePercent%',
|
|
||||||
icon: Icons.people_alt_rounded,
|
|
||||||
),
|
|
||||||
_ActionMetricTile(
|
|
||||||
width: tileWidth,
|
|
||||||
title: 'Scan issues',
|
|
||||||
value: _failedScanCount(summary).toString(),
|
|
||||||
icon: Icons.error_outline_rounded,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
int _failedScanCount(AnalysisSummaryUiModel summary) {
|
|
||||||
final failed = summary.scans * (100 - summary.successRatePercent) / 100;
|
|
||||||
return failed.round().clamp(0, summary.scans).toInt();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ChartCard extends StatelessWidget {
|
|
||||||
const _ChartCard({
|
|
||||||
required this.selectedMetric,
|
|
||||||
required this.onMetricSelected,
|
|
||||||
required this.totalValue,
|
|
||||||
required this.rangeLabel,
|
|
||||||
required this.chart,
|
|
||||||
required this.xLabels,
|
|
||||||
});
|
|
||||||
|
|
||||||
final AnalysisChartMetric selectedMetric;
|
|
||||||
final ValueChanged<AnalysisChartMetric> onMetricSelected;
|
|
||||||
final int totalValue;
|
|
||||||
final String rangeLabel;
|
|
||||||
final Widget chart;
|
|
||||||
final List<String> xLabels;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
final textTheme = Theme.of(context).textTheme;
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
decoration: _cardDecoration(context),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Counter trend',
|
|
||||||
style: textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
rangeLabel,
|
|
||||||
style: textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
totalValue.toString(),
|
|
||||||
style: textTheme.headlineSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_ChartMetricSelector(
|
|
||||||
selected: selectedMetric,
|
|
||||||
onSelected: onMetricSelected,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
chart,
|
|
||||||
if (xLabels.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
_XAxisLabels(labels: xLabels),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ChartMetricSelector extends StatelessWidget {
|
|
||||||
const _ChartMetricSelector({
|
|
||||||
required this.selected,
|
|
||||||
required this.onSelected,
|
|
||||||
});
|
|
||||||
|
|
||||||
final AnalysisChartMetric selected;
|
|
||||||
final ValueChanged<AnalysisChartMetric> onSelected;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return SegmentedButton<AnalysisChartMetric>(
|
|
||||||
showSelectedIcon: false,
|
|
||||||
segments: AnalysisChartMetric.values
|
|
||||||
.map(
|
|
||||||
(metric) => ButtonSegment(value: metric, label: Text(metric.label)),
|
|
||||||
)
|
|
||||||
.toList(growable: false),
|
|
||||||
selected: <AnalysisChartMetric>{selected},
|
|
||||||
onSelectionChanged: (selection) {
|
|
||||||
if (selection.isEmpty) return;
|
|
||||||
onSelected(selection.first);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _OperationalNotes extends StatelessWidget {
|
|
||||||
const _OperationalNotes({required this.state});
|
|
||||||
|
|
||||||
final AnalysisUiState state;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final summary = state.summary ?? AnalysisSummaryUiModel.empty;
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
final textTheme = Theme.of(context).textTheme;
|
|
||||||
final pointsPerScan = state.pointsPerScan ?? 0;
|
|
||||||
final successRate = summary.successRatePercent;
|
|
||||||
final needsScanCheck = successRate < 98;
|
|
||||||
final hasHighRedemption = pointsPerScan >= 1.2;
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
decoration: _cardDecoration(context),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Cashier notes',
|
|
||||||
style: textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_NoteRow(
|
|
||||||
icon: needsScanCheck
|
|
||||||
? Icons.build_circle_rounded
|
|
||||||
: Icons.check_circle_rounded,
|
|
||||||
title: needsScanCheck ? 'Check QR retry flow' : 'QR scans stable',
|
|
||||||
body: needsScanCheck
|
|
||||||
? 'Success is below target. Confirm lighting and member QR focus at the counter.'
|
|
||||||
: 'Scan success is within the expected cashier range.',
|
|
||||||
),
|
|
||||||
Divider(height: 24, color: colorScheme.outlineVariant),
|
|
||||||
_NoteRow(
|
|
||||||
icon: hasHighRedemption
|
|
||||||
? Icons.local_offer_rounded
|
|
||||||
: Icons.payments_rounded,
|
|
||||||
title: hasHighRedemption
|
|
||||||
? 'Redemption demand is high'
|
|
||||||
: 'Redemption pace is normal',
|
|
||||||
body: hasHighRedemption
|
|
||||||
? 'Keep reward terms visible before checkout to reduce voids.'
|
|
||||||
: 'Coin use is tracking cleanly against member scans.',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _HeroMetric extends StatelessWidget {
|
|
||||||
const _HeroMetric({
|
|
||||||
required this.label,
|
|
||||||
required this.value,
|
|
||||||
required this.icon,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String label;
|
|
||||||
final String value;
|
|
||||||
final IconData icon;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
final textTheme = Theme.of(context).textTheme;
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
constraints: const BoxConstraints(minHeight: 104),
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceContainerHighest.withOpacity(0.42),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Icon(icon, color: colorScheme.primary, size: 22),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Text(
|
|
||||||
value,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: textTheme.headlineSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ActionMetricTile extends StatelessWidget {
|
|
||||||
const _ActionMetricTile({
|
|
||||||
required this.width,
|
|
||||||
required this.title,
|
|
||||||
required this.value,
|
|
||||||
required this.icon,
|
|
||||||
});
|
|
||||||
|
|
||||||
final double width;
|
|
||||||
final String title;
|
|
||||||
final String value;
|
|
||||||
final IconData icon;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
final textTheme = Theme.of(context).textTheme;
|
|
||||||
|
|
||||||
return SizedBox(
|
|
||||||
width: width,
|
|
||||||
child: Container(
|
|
||||||
constraints: const BoxConstraints(minHeight: 96),
|
|
||||||
decoration: _cardDecoration(context),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.primaryContainer,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Icon(
|
|
||||||
icon,
|
|
||||||
color: colorScheme.onPrimaryContainer,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
value,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: textTheme.titleLarge?.copyWith(
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _InlineStatBar extends StatelessWidget {
|
|
||||||
const _InlineStatBar({required this.items});
|
|
||||||
|
|
||||||
final List<_InlineStatItem> items;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border(
|
|
||||||
top: BorderSide(color: colorScheme.outlineVariant.withOpacity(0.7)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
for (var i = 0; i < items.length; i++) ...[
|
|
||||||
if (i > 0)
|
|
||||||
SizedBox(
|
|
||||||
height: 34,
|
|
||||||
child: VerticalDivider(
|
|
||||||
width: 16,
|
|
||||||
color: colorScheme.outlineVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(child: items[i]),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _InlineStatItem extends StatelessWidget {
|
|
||||||
const _InlineStatItem({required this.label, required this.value});
|
|
||||||
|
|
||||||
final String label;
|
|
||||||
final String value;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
final textTheme = Theme.of(context).textTheme;
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
value,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w900),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: textTheme.labelSmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _StatusPill extends StatelessWidget {
|
|
||||||
const _StatusPill({required this.label, required this.icon});
|
|
||||||
|
|
||||||
final String label;
|
|
||||||
final IconData icon;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
final textTheme = Theme.of(context).textTheme;
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.secondaryContainer.withOpacity(0.78),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(icon, size: 15, color: colorScheme.onSecondaryContainer),
|
|
||||||
const SizedBox(width: 5),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: textTheme.labelMedium?.copyWith(
|
|
||||||
color: colorScheme.onSecondaryContainer,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _NoteRow extends StatelessWidget {
|
|
||||||
const _NoteRow({required this.icon, required this.title, required this.body});
|
|
||||||
|
|
||||||
final IconData icon;
|
|
||||||
final String title;
|
|
||||||
final String body;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
final textTheme = Theme.of(context).textTheme;
|
|
||||||
|
|
||||||
return Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Icon(icon, color: colorScheme.primary, size: 22),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style: textTheme.titleSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
body,
|
|
||||||
style: textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
height: 1.35,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _XAxisLabels extends StatelessWidget {
|
|
||||||
const _XAxisLabels({required this.labels});
|
|
||||||
|
|
||||||
final List<String> labels;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final textStyle = Theme.of(context).textTheme.labelSmall;
|
|
||||||
final onSurfaceVariant = Theme.of(context).colorScheme.onSurfaceVariant;
|
|
||||||
|
|
||||||
final count = labels.length;
|
|
||||||
final desiredTicks = 4;
|
|
||||||
final step = count <= desiredTicks ? 1 : (count - 1) ~/ (desiredTicks - 1);
|
|
||||||
|
|
||||||
final ticks = <int>{0, count - 1};
|
|
||||||
for (var i = 0; i < count; i += step) {
|
|
||||||
ticks.add(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Row(
|
|
||||||
children: List.generate(count, (i) {
|
|
||||||
final show = ticks.contains(i);
|
|
||||||
return Expanded(
|
|
||||||
child: Align(
|
|
||||||
alignment: i == 0
|
|
||||||
? Alignment.centerLeft
|
|
||||||
: i == count - 1
|
|
||||||
? Alignment.centerRight
|
|
||||||
: Alignment.center,
|
|
||||||
child: Text(
|
|
||||||
show ? labels[i] : '',
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: textStyle?.copyWith(color: onSurfaceVariant),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LoadingBody extends StatelessWidget {
|
|
||||||
const _LoadingBody();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return const Padding(
|
|
||||||
padding: EdgeInsets.only(top: 40),
|
|
||||||
child: Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
CircularProgressIndicator(),
|
|
||||||
SizedBox(height: 12),
|
|
||||||
Text('Loading cashier loyalty analysis...'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ErrorBody extends StatelessWidget {
|
|
||||||
const _ErrorBody({required this.onRetry});
|
|
||||||
|
|
||||||
final VoidCallback onRetry;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 40),
|
|
||||||
child: Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
const Text('Unable to load cashier analysis'),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
TextButton(onPressed: onRetry, child: const Text('Retry')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
BoxDecoration _cardDecoration(BuildContext context) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return BoxDecoration(
|
|
||||||
color: colorScheme.surface,
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.55)),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withOpacity(0.05),
|
|
||||||
blurRadius: 12,
|
|
||||||
offset: const Offset(0, 5),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,72 +0,0 @@
|
|||||||
import 'package:cb_prestige_qr/core/storage/local_storage_service.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/auth/domain/entities/user.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/auth/domain/entities/user_profile.dart';
|
|
||||||
|
|
||||||
abstract class AuthLocalDataSource {
|
|
||||||
Future<void> saveSession(User user);
|
|
||||||
Future<UserProfile?> getSavedUserProfile();
|
|
||||||
Future<void> clearSession();
|
|
||||||
}
|
|
||||||
|
|
||||||
class AuthLocalDataSourceImpl implements AuthLocalDataSource {
|
|
||||||
AuthLocalDataSourceImpl(this._storage);
|
|
||||||
|
|
||||||
final LocalStorageService _storage;
|
|
||||||
|
|
||||||
static const _usernameKey = 'logged_in_username';
|
|
||||||
static const _displayNameKey = 'logged_in_display_name';
|
|
||||||
static const _roleKey = 'logged_in_role';
|
|
||||||
static const _branchKey = 'logged_in_branch';
|
|
||||||
static const _tokenKey = 'logged_in_token';
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> saveSession(User user) async {
|
|
||||||
await _storage.setString(_usernameKey, user.username);
|
|
||||||
await _storage.setString(_displayNameKey, user.displayName);
|
|
||||||
await _storage.setString(_roleKey, user.roleLabel);
|
|
||||||
await _storage.setString(_branchKey, user.branchLabel);
|
|
||||||
await _storage.setString(_tokenKey, user.authToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<UserProfile?> getSavedUserProfile() async {
|
|
||||||
final username = await _storage.getString(_usernameKey);
|
|
||||||
if (username == null || username.trim().isEmpty) return null;
|
|
||||||
|
|
||||||
return UserProfile(
|
|
||||||
username: username,
|
|
||||||
displayName:
|
|
||||||
await _storage.getString(_displayNameKey) ??
|
|
||||||
_displayNameFromUsername(username),
|
|
||||||
roleLabel: await _storage.getString(_roleKey) ?? 'Cashier',
|
|
||||||
branchLabel: await _storage.getString(_branchKey) ?? 'Prestige Counter',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> clearSession() async {
|
|
||||||
await _storage.remove(_usernameKey);
|
|
||||||
await _storage.remove(_displayNameKey);
|
|
||||||
await _storage.remove(_roleKey);
|
|
||||||
await _storage.remove(_branchKey);
|
|
||||||
await _storage.remove(_tokenKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _displayNameFromUsername(String username) {
|
|
||||||
final words = username
|
|
||||||
.replaceAll(RegExp(r'[._-]+'), ' ')
|
|
||||||
.trim()
|
|
||||||
.split(RegExp(r'\s+'))
|
|
||||||
.where((word) => word.isNotEmpty)
|
|
||||||
.toList(growable: false);
|
|
||||||
if (words.isEmpty) return 'Cashier';
|
|
||||||
|
|
||||||
return words.map(_capitalize).join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _capitalize(String value) {
|
|
||||||
if (value.isEmpty) return value;
|
|
||||||
if (value.length == 1) return value.toUpperCase();
|
|
||||||
return '${value.substring(0, 1).toUpperCase()}${value.substring(1)}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,48 +0,0 @@
|
|||||||
import 'package:cb_prestige_qr/features/auth/data/models/login_request_model.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/auth/data/models/login_response_model.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/auth/domain/entities/user.dart';
|
|
||||||
|
|
||||||
abstract class AuthRemoteDataSource {
|
|
||||||
Future<LoginResponseModel> login(LoginRequestModel request);
|
|
||||||
}
|
|
||||||
|
|
||||||
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
|
|
||||||
const AuthRemoteDataSourceImpl();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<LoginResponseModel> login(LoginRequestModel request) async {
|
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 800));
|
|
||||||
|
|
||||||
final username = request.username.trim();
|
|
||||||
if (username.isEmpty || request.password.trim().isEmpty) {
|
|
||||||
throw Exception('Username and password are required.');
|
|
||||||
}
|
|
||||||
|
|
||||||
final user = User(
|
|
||||||
username: username,
|
|
||||||
displayName: _displayNameFromUsername(username),
|
|
||||||
roleLabel: 'Cashier',
|
|
||||||
branchLabel: 'Prestige Counter',
|
|
||||||
authToken: 'demo-token-${request.deviceId}-$username',
|
|
||||||
);
|
|
||||||
|
|
||||||
return LoginResponseModel(user: user, token: user.authToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _displayNameFromUsername(String username) {
|
|
||||||
final words = username
|
|
||||||
.replaceAll(RegExp(r'[._-]+'), ' ')
|
|
||||||
.trim()
|
|
||||||
.split(RegExp(r'\s+'))
|
|
||||||
.where((word) => word.isNotEmpty)
|
|
||||||
.toList(growable: false);
|
|
||||||
if (words.isEmpty) return 'Cashier';
|
|
||||||
return words.map(_capitalize).join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _capitalize(String value) {
|
|
||||||
if (value.isEmpty) return value;
|
|
||||||
if (value.length == 1) return value.toUpperCase();
|
|
||||||
return '${value.substring(0, 1).toUpperCase()}${value.substring(1)}';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,19 +0,0 @@
|
|||||||
class LoginRequestModel {
|
|
||||||
const LoginRequestModel({
|
|
||||||
required this.username,
|
|
||||||
required this.password,
|
|
||||||
required this.deviceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String username;
|
|
||||||
final String password;
|
|
||||||
final String deviceId;
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
return <String, dynamic>{
|
|
||||||
'username': username,
|
|
||||||
'password': password,
|
|
||||||
'device_id': deviceId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,8 +0,0 @@
|
|||||||
import 'package:cb_prestige_qr/features/auth/domain/entities/user.dart';
|
|
||||||
|
|
||||||
class LoginResponseModel {
|
|
||||||
const LoginResponseModel({required this.user, required this.token});
|
|
||||||
|
|
||||||
final User user;
|
|
||||||
final String token;
|
|
||||||
}
|
|
||||||
@ -1,50 +0,0 @@
|
|||||||
import 'package:cb_prestige_qr/core/services/device_id_service.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/auth/data/data_sources/auth_local_data_source.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/auth/data/data_sources/auth_remote_data_source.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/auth/data/models/login_request_model.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/auth/domain/entities/user_profile.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/auth/domain/repositories/auth_repository.dart';
|
|
||||||
|
|
||||||
class AuthRepositoryImpl implements AuthRepository {
|
|
||||||
const AuthRepositoryImpl(
|
|
||||||
this._remoteDataSource,
|
|
||||||
this._localDataSource,
|
|
||||||
this._deviceIdService,
|
|
||||||
);
|
|
||||||
|
|
||||||
final AuthRemoteDataSource _remoteDataSource;
|
|
||||||
final AuthLocalDataSource _localDataSource;
|
|
||||||
final DeviceIdService _deviceIdService;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<UserProfile> login({
|
|
||||||
required String username,
|
|
||||||
required String password,
|
|
||||||
}) async {
|
|
||||||
final deviceId = await _deviceIdService.getDeviceId();
|
|
||||||
final response = await _remoteDataSource.login(
|
|
||||||
LoginRequestModel(
|
|
||||||
username: username,
|
|
||||||
password: password,
|
|
||||||
deviceId: deviceId,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await _localDataSource.saveSession(response.user);
|
|
||||||
|
|
||||||
return UserProfile(
|
|
||||||
username: response.user.username,
|
|
||||||
displayName: response.user.displayName,
|
|
||||||
roleLabel: response.user.roleLabel,
|
|
||||||
branchLabel: response.user.branchLabel,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<UserProfile?> getCurrentUserProfile() {
|
|
||||||
return _localDataSource.getSavedUserProfile();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> logout() => _localDataSource.clearSession();
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
import 'package:cb_prestige_qr/core/providers/core_providers.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/auth/data/data_sources/auth_local_data_source.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/auth/data/data_sources/auth_remote_data_source.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/auth/data/repositories/auth_repository_impl.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/auth/domain/repositories/auth_repository.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/auth/domain/use_cases/get_current_user_profile_use_case.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/auth/domain/use_cases/login_use_case.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/auth/domain/use_cases/logout_use_case.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
|
|
||||||
final authLocalDataSourceProvider = Provider<AuthLocalDataSource>(
|
|
||||||
(ref) => AuthLocalDataSourceImpl(ref.watch(localStorageServiceProvider)),
|
|
||||||
);
|
|
||||||
|
|
||||||
final authRemoteDataSourceProvider = Provider<AuthRemoteDataSource>(
|
|
||||||
(ref) => const AuthRemoteDataSourceImpl(),
|
|
||||||
);
|
|
||||||
|
|
||||||
final authRepositoryProvider = Provider<AuthRepository>(
|
|
||||||
(ref) => AuthRepositoryImpl(
|
|
||||||
ref.watch(authRemoteDataSourceProvider),
|
|
||||||
ref.watch(authLocalDataSourceProvider),
|
|
||||||
ref.watch(deviceIdServiceProvider),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
final loginUseCaseProvider = Provider<LoginUseCase>(
|
|
||||||
(ref) => LoginUseCase(ref.watch(authRepositoryProvider)),
|
|
||||||
);
|
|
||||||
|
|
||||||
final getCurrentUserProfileUseCaseProvider =
|
|
||||||
Provider<GetCurrentUserProfileUseCase>(
|
|
||||||
(ref) => GetCurrentUserProfileUseCase(ref.watch(authRepositoryProvider)),
|
|
||||||
);
|
|
||||||
|
|
||||||
final logoutUseCaseProvider = Provider<LogoutUseCase>(
|
|
||||||
(ref) => LogoutUseCase(ref.watch(authRepositoryProvider)),
|
|
||||||
);
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
class User {
|
|
||||||
const User({
|
|
||||||
required this.username,
|
|
||||||
required this.displayName,
|
|
||||||
required this.roleLabel,
|
|
||||||
required this.branchLabel,
|
|
||||||
required this.authToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String username;
|
|
||||||
final String displayName;
|
|
||||||
final String roleLabel;
|
|
||||||
final String branchLabel;
|
|
||||||
final String authToken;
|
|
||||||
}
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
class UserProfile {
|
|
||||||
const UserProfile({
|
|
||||||
required this.username,
|
|
||||||
required this.displayName,
|
|
||||||
required this.roleLabel,
|
|
||||||
required this.branchLabel,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String username;
|
|
||||||
final String displayName;
|
|
||||||
final String roleLabel;
|
|
||||||
final String branchLabel;
|
|
||||||
|
|
||||||
String get initials {
|
|
||||||
final parts = displayName
|
|
||||||
.trim()
|
|
||||||
.split(RegExp(r'\s+'))
|
|
||||||
.where((part) => part.isNotEmpty)
|
|
||||||
.toList(growable: false);
|
|
||||||
if (parts.isEmpty) return 'U';
|
|
||||||
if (parts.length == 1) return parts.first.substring(0, 1).toUpperCase();
|
|
||||||
return '${parts.first.substring(0, 1)}${parts.last.substring(0, 1)}'
|
|
||||||
.toUpperCase();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,12 +0,0 @@
|
|||||||
import '../entities/user_profile.dart';
|
|
||||||
|
|
||||||
abstract class AuthRepository {
|
|
||||||
Future<UserProfile> login({
|
|
||||||
required String username,
|
|
||||||
required String password,
|
|
||||||
});
|
|
||||||
|
|
||||||
Future<UserProfile?> getCurrentUserProfile();
|
|
||||||
|
|
||||||
Future<void> logout();
|
|
||||||
}
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
import '../entities/user_profile.dart';
|
|
||||||
import '../repositories/auth_repository.dart';
|
|
||||||
|
|
||||||
class GetCurrentUserProfileUseCase {
|
|
||||||
const GetCurrentUserProfileUseCase(this._repository);
|
|
||||||
|
|
||||||
final AuthRepository _repository;
|
|
||||||
|
|
||||||
Future<UserProfile?> call() => _repository.getCurrentUserProfile();
|
|
||||||
}
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
import '../entities/user_profile.dart';
|
|
||||||
import '../repositories/auth_repository.dart';
|
|
||||||
|
|
||||||
class LoginUseCase {
|
|
||||||
const LoginUseCase(this._repository);
|
|
||||||
|
|
||||||
final AuthRepository _repository;
|
|
||||||
|
|
||||||
Future<UserProfile> call({
|
|
||||||
required String username,
|
|
||||||
required String password,
|
|
||||||
}) {
|
|
||||||
return _repository.login(username: username, password: password);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,9 +0,0 @@
|
|||||||
import '../repositories/auth_repository.dart';
|
|
||||||
|
|
||||||
class LogoutUseCase {
|
|
||||||
const LogoutUseCase(this._repository);
|
|
||||||
|
|
||||||
final AuthRepository _repository;
|
|
||||||
|
|
||||||
Future<void> call() => _repository.logout();
|
|
||||||
}
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
|
||||||
|
|
||||||
@immutable
|
|
||||||
class LoginState {
|
|
||||||
const LoginState({
|
|
||||||
this.username = '',
|
|
||||||
this.password = '',
|
|
||||||
this.obscurePassword = true,
|
|
||||||
this.isSubmitting = false,
|
|
||||||
this.errorMessage,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String username;
|
|
||||||
final String password;
|
|
||||||
final bool obscurePassword;
|
|
||||||
final bool isSubmitting;
|
|
||||||
final String? errorMessage;
|
|
||||||
|
|
||||||
static const _sentinel = Object();
|
|
||||||
|
|
||||||
LoginState copyWith({
|
|
||||||
String? username,
|
|
||||||
String? password,
|
|
||||||
bool? obscurePassword,
|
|
||||||
bool? isSubmitting,
|
|
||||||
Object? errorMessage = _sentinel,
|
|
||||||
}) {
|
|
||||||
return LoginState(
|
|
||||||
username: username ?? this.username,
|
|
||||||
password: password ?? this.password,
|
|
||||||
obscurePassword: obscurePassword ?? this.obscurePassword,
|
|
||||||
isSubmitting: isSubmitting ?? this.isSubmitting,
|
|
||||||
errorMessage: identical(errorMessage, _sentinel)
|
|
||||||
? this.errorMessage
|
|
||||||
: errorMessage as String?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
import 'package:cb_prestige_qr/features/auth/di/auth_providers.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
|
|
||||||
import 'login_state.dart';
|
|
||||||
|
|
||||||
final loginViewModelProvider = NotifierProvider<LoginViewModel, LoginState>(
|
|
||||||
LoginViewModel.new,
|
|
||||||
);
|
|
||||||
|
|
||||||
class LoginViewModel extends Notifier<LoginState> {
|
|
||||||
@override
|
|
||||||
LoginState build() => const LoginState();
|
|
||||||
|
|
||||||
void updateUsername(String value) {
|
|
||||||
state = state.copyWith(username: value, errorMessage: null);
|
|
||||||
}
|
|
||||||
|
|
||||||
void updatePassword(String value) {
|
|
||||||
state = state.copyWith(password: value, errorMessage: null);
|
|
||||||
}
|
|
||||||
|
|
||||||
void togglePasswordVisibility() {
|
|
||||||
state = state.copyWith(obscurePassword: !state.obscurePassword);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<bool> submit() async {
|
|
||||||
if (state.isSubmitting) return false;
|
|
||||||
|
|
||||||
state = state.copyWith(isSubmitting: true, errorMessage: null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await ref.read(loginUseCaseProvider)(
|
|
||||||
username: state.username.trim(),
|
|
||||||
password: state.password,
|
|
||||||
);
|
|
||||||
state = state.copyWith(isSubmitting: false);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
state = state.copyWith(
|
|
||||||
isSubmitting: false,
|
|
||||||
errorMessage: error.toString().replaceFirst('Exception: ', ''),
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,313 +0,0 @@
|
|||||||
import 'package:cb_prestige_qr/app/navigation/main_shell.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/auth/presentation/viewmodels/login_view_model.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
|
|
||||||
class LoginView extends ConsumerStatefulWidget {
|
|
||||||
const LoginView({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
ConsumerState<LoginView> createState() => _LoginViewState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LoginViewState extends ConsumerState<LoginView> {
|
|
||||||
final _formKey = GlobalKey<FormState>();
|
|
||||||
|
|
||||||
Future<void> _submit() async {
|
|
||||||
final form = _formKey.currentState;
|
|
||||||
if (form == null || !form.validate()) return;
|
|
||||||
|
|
||||||
final didLogin = await ref.read(loginViewModelProvider.notifier).submit();
|
|
||||||
if (!didLogin || !mounted) return;
|
|
||||||
|
|
||||||
Navigator.of(
|
|
||||||
context,
|
|
||||||
).pushReplacement(MaterialPageRoute(builder: (_) => const MainShell()));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final colorScheme = theme.colorScheme;
|
|
||||||
final state = ref.watch(loginViewModelProvider);
|
|
||||||
final viewModel = ref.read(loginViewModelProvider.notifier);
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
body: Container(
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [Color(0xff17181c), Color(0xff25262b), Color(0xff2c2d33)],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: SafeArea(
|
|
||||||
child: Center(
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 20),
|
|
||||||
child: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 440),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xff1d1e23).withValues(alpha: 0.92),
|
|
||||||
borderRadius: BorderRadius.circular(28),
|
|
||||||
border: Border.all(
|
|
||||||
color: Colors.white.withValues(alpha: 0.08),
|
|
||||||
),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withValues(alpha: 0.28),
|
|
||||||
blurRadius: 28,
|
|
||||||
offset: const Offset(0, 18),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Form(
|
|
||||||
key: _formKey,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
height: 56,
|
|
||||||
width: 56,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.primary.withValues(
|
|
||||||
alpha: 0.16,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(18),
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.lock_person_rounded,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 30,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Secure Login',
|
|
||||||
style: theme.textTheme.headlineSmall
|
|
||||||
?.copyWith(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
'Use your username and password to continue.',
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
|
||||||
color: Colors.white70,
|
|
||||||
height: 1.35,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 28),
|
|
||||||
Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(18),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white.withValues(alpha: 0.04),
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
border: Border.all(
|
|
||||||
color: Colors.white.withValues(alpha: 0.06),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
child: Image.asset(
|
|
||||||
'assets/images/logo_white.png',
|
|
||||||
height: 48,
|
|
||||||
width: 48,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 14),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'CB Prestige Banking',
|
|
||||||
style: theme.textTheme.titleMedium
|
|
||||||
?.copyWith(
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
'Sign in to access your QR dashboard.',
|
|
||||||
style: theme.textTheme.bodySmall
|
|
||||||
?.copyWith(color: Colors.white60),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 28),
|
|
||||||
_AuthTextField(
|
|
||||||
initialValue: state.username,
|
|
||||||
label: 'Username',
|
|
||||||
hintText: 'Enter your username',
|
|
||||||
keyboardType: TextInputType.text,
|
|
||||||
prefixIcon: Icons.person_rounded,
|
|
||||||
onChanged: viewModel.updateUsername,
|
|
||||||
validator: (value) {
|
|
||||||
final username = value?.trim() ?? '';
|
|
||||||
if (username.isEmpty) {
|
|
||||||
return 'Username is required';
|
|
||||||
}
|
|
||||||
if (username.length < 3) {
|
|
||||||
return 'Username must be at least 3 characters';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
_AuthTextField(
|
|
||||||
initialValue: state.password,
|
|
||||||
label: 'Password',
|
|
||||||
hintText: 'Enter your password',
|
|
||||||
prefixIcon: Icons.lock_rounded,
|
|
||||||
obscureText: state.obscurePassword,
|
|
||||||
onChanged: viewModel.updatePassword,
|
|
||||||
validator: (value) {
|
|
||||||
final password = value ?? '';
|
|
||||||
if (password.isEmpty) {
|
|
||||||
return 'Password is required';
|
|
||||||
}
|
|
||||||
if (password.length < 6) {
|
|
||||||
return 'Password must be at least 6 characters';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
suffixIcon: IconButton(
|
|
||||||
onPressed: viewModel.togglePasswordVisibility,
|
|
||||||
icon: Icon(
|
|
||||||
state.obscurePassword
|
|
||||||
? Icons.visibility_off_rounded
|
|
||||||
: Icons.visibility_rounded,
|
|
||||||
color: Colors.white70,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Align(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
child: TextButton(
|
|
||||||
onPressed: () {},
|
|
||||||
child: const Text('Forgot password?'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (state.errorMessage != null) ...[
|
|
||||||
Text(
|
|
||||||
state.errorMessage!,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.error,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 14),
|
|
||||||
] else
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: FilledButton(
|
|
||||||
onPressed: state.isSubmitting ? null : _submit,
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor: colorScheme.primary,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(18),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: state.isSubmitting
|
|
||||||
? const SizedBox(
|
|
||||||
height: 20,
|
|
||||||
width: 20,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2.4,
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
|
||||||
Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Text(
|
|
||||||
'Sign In',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _AuthTextField extends StatelessWidget {
|
|
||||||
const _AuthTextField({
|
|
||||||
required this.initialValue,
|
|
||||||
required this.label,
|
|
||||||
required this.hintText,
|
|
||||||
required this.prefixIcon,
|
|
||||||
required this.validator,
|
|
||||||
required this.onChanged,
|
|
||||||
this.keyboardType,
|
|
||||||
this.obscureText = false,
|
|
||||||
this.suffixIcon,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String initialValue;
|
|
||||||
final String label;
|
|
||||||
final String hintText;
|
|
||||||
final IconData prefixIcon;
|
|
||||||
final String? Function(String?) validator;
|
|
||||||
final ValueChanged<String> onChanged;
|
|
||||||
final TextInputType? keyboardType;
|
|
||||||
final bool obscureText;
|
|
||||||
final Widget? suffixIcon;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return TextFormField(
|
|
||||||
initialValue: initialValue,
|
|
||||||
keyboardType: keyboardType,
|
|
||||||
obscureText: obscureText,
|
|
||||||
validator: validator,
|
|
||||||
onChanged: onChanged,
|
|
||||||
style: const TextStyle(color: Colors.white),
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: label,
|
|
||||||
hintText: hintText,
|
|
||||||
prefixIcon: Icon(prefixIcon),
|
|
||||||
suffixIcon: suffixIcon,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,12 +1,12 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
import '../viewmodels/history_ui_state.dart';
|
import '../manager/history_ui_state.dart';
|
||||||
import '../viewmodels/history_view_model.dart';
|
import '../manager/history_view_model.dart';
|
||||||
import '../widgets/history_section_card.dart';
|
import '../widgets/history_section_card.dart';
|
||||||
|
|
||||||
class HistoryView extends ConsumerWidget {
|
class HistoryPage extends ConsumerWidget {
|
||||||
const HistoryView({super.key});
|
const HistoryPage({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../viewmodels/history_ui_state.dart';
|
import '../manager/history_ui_state.dart';
|
||||||
import 'history_item_tile.dart';
|
import 'history_item_tile.dart';
|
||||||
|
|
||||||
class HistorySectionCard extends StatelessWidget {
|
class HistorySectionCard extends StatelessWidget {
|
||||||
@ -57,7 +57,7 @@ class HistorySectionCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 10),
|
||||||
Text(
|
Text(
|
||||||
'${section.totalTransactionsLabel} | ${section.totalAmountLabel}',
|
'${section.totalTransactionsLabel} • ${section.totalAmountLabel}',
|
||||||
style: textTheme.labelMedium?.copyWith(
|
style: textTheme.labelMedium?.copyWith(
|
||||||
color: colorScheme.onPrimary.withOpacity(0.9),
|
color: colorScheme.onPrimary.withOpacity(0.9),
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
|||||||
@ -28,3 +28,4 @@ class HomeLocalDataSourceImpl implements HomeLocalDataSource {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
import '../../domain/entities/recent_scan.dart';
|
import '../../domain/entities/recent_scan.dart';
|
||||||
|
|
||||||
class RecentScanModel extends RecentScan {
|
class RecentScanModel extends RecentScan {
|
||||||
const RecentScanModel({required super.title, required super.subtitle});
|
const RecentScanModel({
|
||||||
|
required super.title,
|
||||||
|
required super.subtitle,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,3 +18,4 @@ class HomeRepositoryImpl implements HomeRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +1,12 @@
|
|||||||
import 'recent_scan.dart';
|
import 'recent_scan.dart';
|
||||||
|
|
||||||
class HomeContent {
|
class HomeContent {
|
||||||
const HomeContent({required this.carouselAssets, required this.recentScans});
|
const HomeContent({
|
||||||
|
required this.carouselAssets,
|
||||||
|
required this.recentScans,
|
||||||
|
});
|
||||||
|
|
||||||
final List<String> carouselAssets;
|
final List<String> carouselAssets;
|
||||||
final List<RecentScan> recentScans;
|
final List<RecentScan> recentScans;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,10 @@
|
|||||||
class RecentScan {
|
class RecentScan {
|
||||||
const RecentScan({required this.title, required this.subtitle});
|
const RecentScan({
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
});
|
||||||
|
|
||||||
final String title;
|
final String title;
|
||||||
final String subtitle;
|
final String subtitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,3 +3,4 @@ import '../entities/home_content.dart';
|
|||||||
abstract class HomeRepository {
|
abstract class HomeRepository {
|
||||||
Future<HomeContent> getHomeContent({int recentLimit = 5});
|
Future<HomeContent> getHomeContent({int recentLimit = 5});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,3 +10,4 @@ class GetHomeContent {
|
|||||||
return _repository.getHomeContent(recentLimit: recentLimit);
|
return _repository.getHomeContent(recentLimit: recentLimit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -42,7 +42,11 @@ class HomeViewModel extends AsyncNotifier<HomeUiState> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void onSeeAllTapped() {}
|
void onSeeAllTapped() {
|
||||||
|
// TODO: navigate to full list
|
||||||
|
}
|
||||||
|
|
||||||
void onRecentScanTapped(int index) {}
|
void onRecentScanTapped(int index) {
|
||||||
|
// TODO: navigate to details
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -1,11 +1,11 @@
|
|||||||
import 'package:carousel_slider/carousel_slider.dart';
|
import 'package:carousel_slider/carousel_slider.dart';
|
||||||
import 'package:cb_prestige_qr/core/presentation/widgets/global_loading_overlay.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/history/presentation/widgets/history_item_tile.dart';
|
import 'package:cb_prestige_qr/features/history/presentation/widgets/history_item_tile.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
import '../viewmodels/home_ui_state.dart';
|
import '../../../../core/presentation/widgets/global_loading_overlay.dart';
|
||||||
import '../viewmodels/home_view_model.dart';
|
import '../manager/home_ui_state.dart';
|
||||||
|
import '../manager/home_view_model.dart';
|
||||||
import '../widgets/home_today_summary_section.dart';
|
import '../widgets/home_today_summary_section.dart';
|
||||||
|
|
||||||
class HomeView extends ConsumerWidget {
|
class HomeView extends ConsumerWidget {
|
||||||
@ -39,17 +39,17 @@ class HomeView extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
body: stateAsync.when(
|
body: stateAsync.when(
|
||||||
data: (state) => _HomeBody(
|
data: (state) => _HomeBody(
|
||||||
state: state,
|
state: state,
|
||||||
onSeeAll: viewModel.onSeeAllTapped,
|
onSeeAll: viewModel.onSeeAllTapped,
|
||||||
onRecentScanTap: viewModel.onRecentScanTapped,
|
onRecentScanTap: viewModel.onRecentScanTapped,
|
||||||
),
|
),
|
||||||
loading: () => const GlobalLoadingOverlay(isLoading: true),
|
loading: () => const GlobalLoadingOverlay(isLoading: true),
|
||||||
error: (error, stackTrace) => Center(
|
error: (error, stackTrace) => Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Text('Failed to load Home'),
|
const Text('Failed to load Home'),
|
||||||
@ -80,7 +80,8 @@ class _HomeBody extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final shellBottomInset = 70 + MediaQuery.of(context).padding.bottom;
|
final shellBottomInset =
|
||||||
|
70 + MediaQuery.of(context).padding.bottom;
|
||||||
final contentBottomPadding = shellBottomInset + 8;
|
final contentBottomPadding = shellBottomInset + 8;
|
||||||
|
|
||||||
return CustomScrollView(
|
return CustomScrollView(
|
||||||
@ -84,6 +84,7 @@ class HomeContextSection extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
hasCustomer
|
hasCustomer
|
||||||
|
|||||||
@ -164,3 +164,4 @@ class _MiniStat extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
0
lib/features/injection_container.dart
Normal file
0
lib/features/injection_container.dart
Normal file
@ -1,5 +1,6 @@
|
|||||||
import 'package:cb_prestige_qr/features/scan/domain/entities/scan_submit_payload.dart';
|
|
||||||
|
|
||||||
abstract interface class ScanRemoteDataSource {
|
abstract interface class ScanRemoteDataSource {
|
||||||
Future<Map<String, dynamic>> submitScan({required ScanSubmitPayload payload});
|
Future<Map<String, dynamic>> submitScan({
|
||||||
|
required String rawValue,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
import 'package:cb_prestige_qr/features/scan/data/data_sources/scan_remote_data_source.dart';
|
import 'package:cb_prestige_qr/features/scan/data/data_sources/scan_remote_data_source.dart';
|
||||||
import 'package:cb_prestige_qr/features/scan/domain/entities/scan_submit_payload.dart';
|
|
||||||
|
|
||||||
class FakeScanRemoteDataSource implements ScanRemoteDataSource {
|
class FakeScanRemoteDataSource implements ScanRemoteDataSource {
|
||||||
@override
|
@override
|
||||||
Future<Map<String, dynamic>> submitScan({
|
Future<Map<String, dynamic>> submitScan({
|
||||||
required ScanSubmitPayload payload,
|
required String rawValue,
|
||||||
}) async {
|
}) async {
|
||||||
return <String, dynamic>{
|
return <String, dynamic>{
|
||||||
'rawValue': payload.token,
|
'rawValue': rawValue,
|
||||||
'scannedAt': DateTime.now().toIso8601String(),
|
'scannedAt': DateTime.now().toIso8601String(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,97 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:cb_prestige_qr/config.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/scan/data/data_sources/scan_remote_data_source.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/scan/domain/entities/scan_submit_payload.dart';
|
|
||||||
|
|
||||||
class HttpScanRemoteDataSource implements ScanRemoteDataSource {
|
|
||||||
HttpScanRemoteDataSource({HttpClient? httpClient})
|
|
||||||
: _httpClient = httpClient ?? HttpClient();
|
|
||||||
|
|
||||||
final HttpClient _httpClient;
|
|
||||||
|
|
||||||
static const _endpoint = String.fromEnvironment(
|
|
||||||
'QR_SCAN_PATH',
|
|
||||||
defaultValue: '/api/qr/scan',
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<Map<String, dynamic>> submitScan({
|
|
||||||
required ScanSubmitPayload payload,
|
|
||||||
}) async {
|
|
||||||
final uri = _resolveUri();
|
|
||||||
final request = await _httpClient.postUrl(uri);
|
|
||||||
request.headers.contentType = ContentType.json;
|
|
||||||
request.write(jsonEncode(payload.toJson()));
|
|
||||||
|
|
||||||
final response = await request.close();
|
|
||||||
final responseBody = await response.transform(utf8.decoder).join();
|
|
||||||
|
|
||||||
if (response.statusCode != HttpStatus.ok) {
|
|
||||||
throw HttpException(
|
|
||||||
_buildStatusMessage(response.statusCode, responseBody),
|
|
||||||
uri: uri,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (responseBody.trim().isEmpty) {
|
|
||||||
return <String, dynamic>{
|
|
||||||
'rawValue': payload.token,
|
|
||||||
'scannedAt': DateTime.now().toIso8601String(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
final decoded = jsonDecode(responseBody);
|
|
||||||
if (decoded is! Map) {
|
|
||||||
throw const FormatException('QR scan response must be a JSON object.');
|
|
||||||
}
|
|
||||||
|
|
||||||
final data = decoded.map((key, value) => MapEntry(key.toString(), value));
|
|
||||||
final isOk = data['OK'] == true || data['ok'] == true;
|
|
||||||
if (!isOk) {
|
|
||||||
throw HttpException(
|
|
||||||
'Submit failed. Response ok flag is false.',
|
|
||||||
uri: uri,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
Uri _resolveUri() {
|
|
||||||
final endpointUri = Uri.tryParse(_endpoint);
|
|
||||||
if (endpointUri != null && endpointUri.hasScheme) {
|
|
||||||
return endpointUri;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (AppConfig.apiBaseUrl.trim().isEmpty) {
|
|
||||||
throw const FormatException(
|
|
||||||
'API_BASE_URL is not configured. Pass --dart-define=API_BASE_URL=https://your-host.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Uri.parse(AppConfig.apiBaseUrl).resolve(_endpoint);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _buildStatusMessage(int statusCode, String responseBody) {
|
|
||||||
final normalizedBody = responseBody.trim();
|
|
||||||
final bodySuffix = normalizedBody.isEmpty
|
|
||||||
? ''
|
|
||||||
: ' Response: $normalizedBody';
|
|
||||||
|
|
||||||
return switch (statusCode) {
|
|
||||||
HttpStatus.badRequest =>
|
|
||||||
'Submit failed with status 400 (Bad Request).$bodySuffix',
|
|
||||||
HttpStatus.unauthorized =>
|
|
||||||
'Submit failed with status 401 (Unauthorized).$bodySuffix',
|
|
||||||
HttpStatus.forbidden =>
|
|
||||||
'Submit failed with status 403 (Forbidden).$bodySuffix',
|
|
||||||
HttpStatus.notFound =>
|
|
||||||
'Submit failed with status 404 (Not Found).$bodySuffix',
|
|
||||||
HttpStatus.internalServerError =>
|
|
||||||
'Submit failed with status 500 (Server Error).$bodySuffix',
|
|
||||||
_ => 'Submit failed. Expected status 200 but got $statusCode.$bodySuffix',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,7 +1,10 @@
|
|||||||
import 'package:cb_prestige_qr/features/scan/domain/entities/scanned_qr.dart';
|
import 'package:cb_prestige_qr/features/scan/domain/entities/scanned_qr.dart';
|
||||||
|
|
||||||
class ScannedQrModel {
|
class ScannedQrModel {
|
||||||
const ScannedQrModel({required this.rawValue, required this.scannedAt});
|
const ScannedQrModel({
|
||||||
|
required this.rawValue,
|
||||||
|
required this.scannedAt,
|
||||||
|
});
|
||||||
|
|
||||||
factory ScannedQrModel.fromJson(Map<String, dynamic> json) {
|
factory ScannedQrModel.fromJson(Map<String, dynamic> json) {
|
||||||
return ScannedQrModel(
|
return ScannedQrModel(
|
||||||
@ -24,3 +27,4 @@ class ScannedQrModel {
|
|||||||
return ScannedQr(rawValue: rawValue, scannedAt: scannedAt);
|
return ScannedQr(rawValue: rawValue, scannedAt: scannedAt);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import 'package:cb_prestige_qr/features/scan/data/data_sources/scan_remote_data_source.dart';
|
import 'package:cb_prestige_qr/features/scan/data/data_sources/scan_remote_data_source.dart';
|
||||||
import 'package:cb_prestige_qr/features/scan/data/models/scanned_qr_model.dart';
|
import 'package:cb_prestige_qr/features/scan/data/models/scanned_qr_model.dart';
|
||||||
import 'package:cb_prestige_qr/features/scan/domain/entities/scanned_qr.dart';
|
import 'package:cb_prestige_qr/features/scan/domain/entities/scanned_qr.dart';
|
||||||
import 'package:cb_prestige_qr/features/scan/domain/entities/scan_submit_payload.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/scan/domain/repositories/scan_repository.dart';
|
import 'package:cb_prestige_qr/features/scan/domain/repositories/scan_repository.dart';
|
||||||
|
|
||||||
class ScanRepositoryImpl implements ScanRepository {
|
class ScanRepositoryImpl implements ScanRepository {
|
||||||
@ -11,17 +10,8 @@ class ScanRepositoryImpl implements ScanRepository {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ScannedQr> processRawValue(String rawValue) async {
|
Future<ScannedQr> processRawValue(String rawValue) async {
|
||||||
return ScannedQr(rawValue: rawValue, scannedAt: DateTime.now());
|
final payload = await _remoteDataSource.submitScan(rawValue: rawValue);
|
||||||
}
|
return ScannedQrModel.fromJson(payload).toEntity();
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ScannedQr> submitScan(String rawValue) async {
|
|
||||||
final request = ScanSubmitPayload.fromRawValue(rawValue: rawValue);
|
|
||||||
await _remoteDataSource.submitScan(payload: request);
|
|
||||||
final normalizedResponse = <String, dynamic>{
|
|
||||||
'rawValue': request.token,
|
|
||||||
'scannedAt': DateTime.now().toIso8601String(),
|
|
||||||
};
|
|
||||||
return ScannedQrModel.fromJson(normalizedResponse).toEntity();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,51 +0,0 @@
|
|||||||
class ScanSubmitPayload {
|
|
||||||
const ScanSubmitPayload({
|
|
||||||
required this.token,
|
|
||||||
required this.merchantId,
|
|
||||||
required this.merchantName,
|
|
||||||
required this.merchantBranchId,
|
|
||||||
required this.scannedByDeviceId,
|
|
||||||
});
|
|
||||||
|
|
||||||
factory ScanSubmitPayload.fromRawValue({required String rawValue}) {
|
|
||||||
final payload = ScanSubmitPayload(
|
|
||||||
token: rawValue.trim(),
|
|
||||||
merchantId: 'merchant-001',
|
|
||||||
merchantName: 'cashier_001',
|
|
||||||
merchantBranchId: 'branch-001',
|
|
||||||
scannedByDeviceId: 'device-001',
|
|
||||||
);
|
|
||||||
payload._validate();
|
|
||||||
return payload;
|
|
||||||
}
|
|
||||||
|
|
||||||
final String token;
|
|
||||||
final String merchantId;
|
|
||||||
final String merchantName;
|
|
||||||
final String merchantBranchId;
|
|
||||||
final String scannedByDeviceId;
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
return <String, dynamic>{
|
|
||||||
'token': token,
|
|
||||||
'merchantId': merchantId,
|
|
||||||
'merchantName': merchantName,
|
|
||||||
'merchantBranchId': merchantBranchId,
|
|
||||||
'scannedByDeviceId': scannedByDeviceId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
void _validate() {
|
|
||||||
final missing = <String>[
|
|
||||||
if (token.trim().isEmpty) 'token',
|
|
||||||
if (merchantId.trim().isEmpty) 'merchantId',
|
|
||||||
if (merchantName.trim().isEmpty) 'merchantName',
|
|
||||||
if (merchantBranchId.trim().isEmpty) 'merchantBranchId',
|
|
||||||
if (scannedByDeviceId.trim().isEmpty) 'scannedByDeviceId',
|
|
||||||
];
|
|
||||||
if (missing.isEmpty) return;
|
|
||||||
throw FormatException(
|
|
||||||
'Scanned QR is missing required fields: ${missing.join(', ')}.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +1,10 @@
|
|||||||
class ScannedQr {
|
class ScannedQr {
|
||||||
const ScannedQr({required this.rawValue, required this.scannedAt});
|
const ScannedQr({
|
||||||
|
required this.rawValue,
|
||||||
|
required this.scannedAt,
|
||||||
|
});
|
||||||
|
|
||||||
final String rawValue;
|
final String rawValue;
|
||||||
final DateTime scannedAt;
|
final DateTime scannedAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,5 +2,5 @@ import 'package:cb_prestige_qr/features/scan/domain/entities/scanned_qr.dart';
|
|||||||
|
|
||||||
abstract interface class ScanRepository {
|
abstract interface class ScanRepository {
|
||||||
Future<ScannedQr> processRawValue(String rawValue);
|
Future<ScannedQr> processRawValue(String rawValue);
|
||||||
Future<ScannedQr> submitScan(String rawValue);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,3 +10,4 @@ class ProcessScanUseCase {
|
|||||||
return _repository.processRawValue(rawValue);
|
return _repository.processRawValue(rawValue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
70
lib/features/scan/presentation/manager/scan_controller.dart
Normal file
70
lib/features/scan/presentation/manager/scan_controller.dart
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:cb_prestige_qr/features/scan/scan_providers.dart';
|
||||||
|
|
||||||
|
part 'scan_controller.g.dart';
|
||||||
|
|
||||||
|
class ScanState {
|
||||||
|
const ScanState({
|
||||||
|
this.isScanning = true,
|
||||||
|
this.isProcessing = false,
|
||||||
|
this.scannedValue,
|
||||||
|
this.errorMessage,
|
||||||
|
});
|
||||||
|
|
||||||
|
final bool isScanning;
|
||||||
|
final bool isProcessing;
|
||||||
|
final String? scannedValue;
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
static const _sentinel = Object();
|
||||||
|
|
||||||
|
ScanState copyWith({
|
||||||
|
bool? isScanning,
|
||||||
|
bool? isProcessing,
|
||||||
|
Object? scannedValue = _sentinel,
|
||||||
|
Object? errorMessage = _sentinel,
|
||||||
|
}) {
|
||||||
|
return ScanState(
|
||||||
|
isScanning: isScanning ?? this.isScanning,
|
||||||
|
isProcessing: isProcessing ?? this.isProcessing,
|
||||||
|
scannedValue:
|
||||||
|
identical(scannedValue, _sentinel) ? this.scannedValue : scannedValue as String?,
|
||||||
|
errorMessage:
|
||||||
|
identical(errorMessage, _sentinel) ? this.errorMessage : errorMessage as String?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
class ScanController extends _$ScanController {
|
||||||
|
@override
|
||||||
|
ScanState build() => const ScanState();
|
||||||
|
|
||||||
|
Future<void> onBarcodeDetected(String value) async {
|
||||||
|
if (!state.isScanning || state.isProcessing) return;
|
||||||
|
state = state.copyWith(
|
||||||
|
isScanning: false,
|
||||||
|
isProcessing: true,
|
||||||
|
errorMessage: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await ref.read(processScanUseCaseProvider)(value);
|
||||||
|
state = state.copyWith(
|
||||||
|
isProcessing: false,
|
||||||
|
scannedValue: result.rawValue,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
state = state.copyWith(
|
||||||
|
isProcessing: false,
|
||||||
|
isScanning: true,
|
||||||
|
scannedValue: null,
|
||||||
|
errorMessage: e.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void resumeScanning() {
|
||||||
|
state = const ScanState(isScanning: true, isProcessing: false, scannedValue: null, errorMessage: null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,62 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'scan_controller.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
|
||||||
|
@ProviderFor(ScanController)
|
||||||
|
final scanControllerProvider = ScanControllerProvider._();
|
||||||
|
|
||||||
|
final class ScanControllerProvider
|
||||||
|
extends $NotifierProvider<ScanController, ScanState> {
|
||||||
|
ScanControllerProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'scanControllerProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$scanControllerHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
ScanController create() => ScanController();
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(ScanState value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<ScanState>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$scanControllerHash() => r'5984074ff43ae1904e2025b355a689580d402a09';
|
||||||
|
|
||||||
|
abstract class _$ScanController extends $Notifier<ScanState> {
|
||||||
|
ScanState build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final ref = this.ref as $Ref<ScanState, ScanState>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<ScanState, ScanState>,
|
||||||
|
ScanState,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleCreate(ref, build);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:cb_prestige_qr/core/presentation/widgets/start_loading_overlay.dart';
|
import 'package:cb_prestige_qr/core/presentation/widgets/start_loading_overlay.dart';
|
||||||
import 'package:cb_prestige_qr/features/scan/presentation/viewmodels/scan_controller.dart';
|
import 'package:cb_prestige_qr/features/scan/presentation/manager/scan_controller.dart';
|
||||||
import 'package:cb_prestige_qr/features/scan/presentation/views/scan_result_page.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:mobile_scanner/mobile_scanner.dart';
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
||||||
@ -16,7 +15,6 @@ class ScanPage extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _ScanPageState extends ConsumerState<ScanPage> {
|
class _ScanPageState extends ConsumerState<ScanPage> {
|
||||||
late final MobileScannerController _scannerController;
|
late final MobileScannerController _scannerController;
|
||||||
String? _openedResultValue;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -49,22 +47,6 @@ class _ScanPageState extends ConsumerState<ScanPage> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ref.listen<String?>(scanControllerProvider.select((s) => s.scannedValue), (
|
|
||||||
previous,
|
|
||||||
next,
|
|
||||||
) {
|
|
||||||
if (next == null || next == _openedResultValue) return;
|
|
||||||
_openedResultValue = next;
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
||||||
if (!mounted) return;
|
|
||||||
await Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(builder: (_) => ScanResultPage(rawValue: next)),
|
|
||||||
);
|
|
||||||
_openedResultValue = null;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
final scanState = ref.watch(scanControllerProvider);
|
final scanState = ref.watch(scanControllerProvider);
|
||||||
|
|
||||||
void onDetect(BarcodeCapture capture) {
|
void onDetect(BarcodeCapture capture) {
|
||||||
@ -103,7 +85,9 @@ class _ScanPageState extends ConsumerState<ScanPage> {
|
|||||||
child: Center(child: CircularProgressIndicator()),
|
child: Center(child: CircularProgressIndicator()),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!scanState.isProcessing && scanState.errorMessage != null)
|
if (!scanState.isProcessing &&
|
||||||
|
(scanState.scannedValue != null ||
|
||||||
|
scanState.errorMessage != null))
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: ColoredBox(
|
child: ColoredBox(
|
||||||
color: const Color(0x66000000),
|
color: const Color(0x66000000),
|
||||||
@ -131,7 +115,11 @@ class _ScanPageState extends ConsumerState<ScanPage> {
|
|||||||
).textTheme.titleLarge,
|
).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
SelectableText(scanState.errorMessage ?? ''),
|
SelectableText(
|
||||||
|
scanState.errorMessage ??
|
||||||
|
scanState.scannedValue ??
|
||||||
|
'',
|
||||||
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@ -1,18 +0,0 @@
|
|||||||
import 'package:cb_prestige_qr/features/scan/data/data_sources/scan_remote_data_source.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/scan/data/data_sources/scan_remote_data_source_http.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/scan/data/repositories/scan_repository_impl.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/scan/domain/repositories/scan_repository.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/scan/domain/use_cases/process_scan_use_case.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
|
|
||||||
final scanRemoteDataSourceProvider = Provider<ScanRemoteDataSource>(
|
|
||||||
(ref) => HttpScanRemoteDataSource(),
|
|
||||||
);
|
|
||||||
|
|
||||||
final scanRepositoryProvider = Provider<ScanRepository>(
|
|
||||||
(ref) => ScanRepositoryImpl(ref.watch(scanRemoteDataSourceProvider)),
|
|
||||||
);
|
|
||||||
|
|
||||||
final processScanUseCaseProvider = Provider<ProcessScanUseCase>(
|
|
||||||
(ref) => ProcessScanUseCase(ref.watch(scanRepositoryProvider)),
|
|
||||||
);
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
import 'package:cb_prestige_qr/features/scan/presentation/providers/scan_providers.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
|
|
||||||
final scanControllerProvider = NotifierProvider<ScanController, ScanState>(
|
|
||||||
ScanController.new,
|
|
||||||
);
|
|
||||||
|
|
||||||
class ScanState {
|
|
||||||
const ScanState({
|
|
||||||
this.isScanning = true,
|
|
||||||
this.isProcessing = false,
|
|
||||||
this.isSubmitting = false,
|
|
||||||
this.scannedValue,
|
|
||||||
this.errorMessage,
|
|
||||||
});
|
|
||||||
|
|
||||||
final bool isScanning;
|
|
||||||
final bool isProcessing;
|
|
||||||
final bool isSubmitting;
|
|
||||||
final String? scannedValue;
|
|
||||||
final String? errorMessage;
|
|
||||||
|
|
||||||
static const _sentinel = Object();
|
|
||||||
|
|
||||||
ScanState copyWith({
|
|
||||||
bool? isScanning,
|
|
||||||
bool? isProcessing,
|
|
||||||
bool? isSubmitting,
|
|
||||||
Object? scannedValue = _sentinel,
|
|
||||||
Object? errorMessage = _sentinel,
|
|
||||||
}) {
|
|
||||||
return ScanState(
|
|
||||||
isScanning: isScanning ?? this.isScanning,
|
|
||||||
isProcessing: isProcessing ?? this.isProcessing,
|
|
||||||
isSubmitting: isSubmitting ?? this.isSubmitting,
|
|
||||||
scannedValue: identical(scannedValue, _sentinel)
|
|
||||||
? this.scannedValue
|
|
||||||
: scannedValue as String?,
|
|
||||||
errorMessage: identical(errorMessage, _sentinel)
|
|
||||||
? this.errorMessage
|
|
||||||
: errorMessage as String?,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ScanController extends Notifier<ScanState> {
|
|
||||||
@override
|
|
||||||
ScanState build() => const ScanState();
|
|
||||||
|
|
||||||
Future<void> onBarcodeDetected(String value) async {
|
|
||||||
if (!state.isScanning || state.isProcessing) return;
|
|
||||||
state = state.copyWith(
|
|
||||||
isScanning: false,
|
|
||||||
isProcessing: true,
|
|
||||||
errorMessage: null,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
final result = await ref.read(processScanUseCaseProvider)(value);
|
|
||||||
state = state.copyWith(
|
|
||||||
isProcessing: false,
|
|
||||||
scannedValue: result.rawValue,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
state = state.copyWith(
|
|
||||||
isProcessing: false,
|
|
||||||
isScanning: true,
|
|
||||||
scannedValue: null,
|
|
||||||
errorMessage: error.toString(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> submitScannedValue(String value) async {
|
|
||||||
if (state.isSubmitting) return;
|
|
||||||
state = state.copyWith(isSubmitting: true, errorMessage: null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await ref.read(scanRepositoryProvider).submitScan(value);
|
|
||||||
state = state.copyWith(
|
|
||||||
isSubmitting: false,
|
|
||||||
scannedValue: null,
|
|
||||||
errorMessage: null,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
state = state.copyWith(
|
|
||||||
isSubmitting: false,
|
|
||||||
errorMessage: error.toString(),
|
|
||||||
);
|
|
||||||
rethrow;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void resumeScanning() {
|
|
||||||
state = const ScanState(
|
|
||||||
isScanning: true,
|
|
||||||
isProcessing: false,
|
|
||||||
isSubmitting: false,
|
|
||||||
scannedValue: null,
|
|
||||||
errorMessage: null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,295 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:cb_prestige_qr/app/navigation/main_shell.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/scan/presentation/viewmodels/scan_controller.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:jwt_decoder/jwt_decoder.dart';
|
|
||||||
|
|
||||||
class ScanResultPage extends ConsumerWidget {
|
|
||||||
const ScanResultPage({super.key, required this.rawValue});
|
|
||||||
|
|
||||||
final String rawValue;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final colorScheme = theme.colorScheme;
|
|
||||||
final scanState = ref.watch(scanControllerProvider);
|
|
||||||
final isSubmitting = scanState.isSubmitting;
|
|
||||||
final previewValue = _buildPreviewValue(rawValue);
|
|
||||||
|
|
||||||
return PopScope<void>(
|
|
||||||
canPop: !isSubmitting,
|
|
||||||
onPopInvokedWithResult: (didPop, result) {
|
|
||||||
if (!didPop) return;
|
|
||||||
ref.read(scanControllerProvider.notifier).resumeScanning();
|
|
||||||
},
|
|
||||||
child: Scaffold(
|
|
||||||
backgroundColor: theme.scaffoldBackgroundColor,
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('QR Result'),
|
|
||||||
backgroundColor: theme.scaffoldBackgroundColor,
|
|
||||||
surfaceTintColor: theme.scaffoldBackgroundColor,
|
|
||||||
elevation: 0,
|
|
||||||
scrolledUnderElevation: 0,
|
|
||||||
),
|
|
||||||
body: SafeArea(
|
|
||||||
child: ListView(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surface,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
border: Border.all(
|
|
||||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
|
||||||
),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withValues(alpha: 0.06),
|
|
||||||
blurRadius: 14,
|
|
||||||
offset: const Offset(0, 6),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 46,
|
|
||||||
height: 46,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.primaryContainer,
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Icon(
|
|
||||||
Icons.qr_code_2_rounded,
|
|
||||||
color: colorScheme.onPrimaryContainer,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Scanned QR',
|
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
'Review before submitting',
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
SelectableText(
|
|
||||||
previewValue,
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
|
||||||
height: 1.4,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (scanState.errorMessage != null) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
scanState.errorMessage!,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.error,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: OutlinedButton(
|
|
||||||
onPressed: isSubmitting
|
|
||||||
? null
|
|
||||||
: () {
|
|
||||||
ref
|
|
||||||
.read(scanControllerProvider.notifier)
|
|
||||||
.resumeScanning();
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
child: const Text('Cancel'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: FilledButton(
|
|
||||||
onPressed: isSubmitting
|
|
||||||
? null
|
|
||||||
: () async {
|
|
||||||
try {
|
|
||||||
final navigator = Navigator.of(context);
|
|
||||||
await ref
|
|
||||||
.read(scanControllerProvider.notifier)
|
|
||||||
.submitScannedValue(rawValue);
|
|
||||||
if (!context.mounted) return;
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text('QR submitted successfully.'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
ref.read(navIndexProvider.notifier).setIndex(0);
|
|
||||||
ref
|
|
||||||
.read(scanControllerProvider.notifier)
|
|
||||||
.resumeScanning();
|
|
||||||
navigator.pop();
|
|
||||||
navigator.pop();
|
|
||||||
} catch (_) {
|
|
||||||
if (!context.mounted) return;
|
|
||||||
final errorMessage =
|
|
||||||
ref
|
|
||||||
.read(scanControllerProvider)
|
|
||||||
.errorMessage ??
|
|
||||||
'Unable to submit QR.';
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
backgroundColor: colorScheme.errorContainer,
|
|
||||||
content: Text(
|
|
||||||
errorMessage,
|
|
||||||
style: TextStyle(
|
|
||||||
color: colorScheme.onErrorContainer,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: isSubmitting
|
|
||||||
? const SizedBox(
|
|
||||||
width: 20,
|
|
||||||
height: 20,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2.4,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Text('Submit'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String _buildPreviewValue(String rawValue) {
|
|
||||||
final decodedJson =
|
|
||||||
_tryDecodeJsonObject(rawValue) ??
|
|
||||||
_tryDecodeJwtWithPackage(rawValue) ??
|
|
||||||
_tryExtractAndDecodeJwt(rawValue) ??
|
|
||||||
_tryDecodeFromUri(rawValue) ??
|
|
||||||
_tryDecodeBase64Json(rawValue, urlSafe: true) ??
|
|
||||||
_tryDecodeBase64Json(rawValue, urlSafe: false);
|
|
||||||
if (decodedJson != null) {
|
|
||||||
const encoder = JsonEncoder.withIndent(' ');
|
|
||||||
return encoder.convert(decodedJson);
|
|
||||||
}
|
|
||||||
|
|
||||||
return _tryDecodeBase64Text(rawValue, urlSafe: true) ??
|
|
||||||
_tryDecodeBase64Text(rawValue, urlSafe: false) ??
|
|
||||||
rawValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic>? _tryDecodeJsonObject(String value) {
|
|
||||||
try {
|
|
||||||
final decoded = jsonDecode(value);
|
|
||||||
if (decoded is! Map) return null;
|
|
||||||
return decoded.map((key, value) => MapEntry(key.toString(), value));
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic>? _tryDecodeJwtWithPackage(String value) {
|
|
||||||
try {
|
|
||||||
return JwtDecoder.tryDecode(value.trim());
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic>? _tryExtractAndDecodeJwt(String value) {
|
|
||||||
final match = RegExp(
|
|
||||||
r'([A-Za-z0-9\-_]+=*\.[A-Za-z0-9\-_]+=*\.[A-Za-z0-9\-_]+=*)',
|
|
||||||
).firstMatch(value);
|
|
||||||
if (match == null) return null;
|
|
||||||
return _tryDecodeJwtWithPackage(match.group(1)!);
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic>? _tryDecodeFromUri(String value) {
|
|
||||||
final uri = Uri.tryParse(value.trim());
|
|
||||||
if (uri == null) return null;
|
|
||||||
|
|
||||||
for (final entry in uri.queryParameters.entries) {
|
|
||||||
final decoded =
|
|
||||||
_tryDecodeJwtWithPackage(entry.value) ??
|
|
||||||
_tryExtractAndDecodeJwt(entry.value) ??
|
|
||||||
_tryDecodeJsonObject(entry.value) ??
|
|
||||||
_tryDecodeBase64Json(entry.value, urlSafe: true) ??
|
|
||||||
_tryDecodeBase64Json(entry.value, urlSafe: false);
|
|
||||||
if (decoded != null) return decoded;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic>? _tryDecodeBase64Json(
|
|
||||||
String value, {
|
|
||||||
required bool urlSafe,
|
|
||||||
}) {
|
|
||||||
try {
|
|
||||||
final normalized = urlSafe
|
|
||||||
? base64Url.normalize(value)
|
|
||||||
: base64.normalize(value);
|
|
||||||
final bytes = urlSafe
|
|
||||||
? base64Url.decode(normalized)
|
|
||||||
: base64.decode(normalized);
|
|
||||||
final decoded = utf8.decode(bytes);
|
|
||||||
final payload = jsonDecode(decoded);
|
|
||||||
if (payload is! Map) return null;
|
|
||||||
return payload.map((key, value) => MapEntry(key.toString(), value));
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String? _tryDecodeBase64Text(String value, {required bool urlSafe}) {
|
|
||||||
try {
|
|
||||||
final normalized = urlSafe
|
|
||||||
? base64Url.normalize(value)
|
|
||||||
: base64.normalize(value);
|
|
||||||
final bytes = urlSafe
|
|
||||||
? base64Url.decode(normalized)
|
|
||||||
: base64.decode(normalized);
|
|
||||||
final decoded = utf8.decode(bytes).trim();
|
|
||||||
if (decoded.isEmpty || decoded == value) return null;
|
|
||||||
return decoded;
|
|
||||||
} catch (_) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
24
lib/features/scan/scan_providers.dart
Normal file
24
lib/features/scan/scan_providers.dart
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import 'package:cb_prestige_qr/features/scan/data/data_sources/scan_remote_data_source.dart';
|
||||||
|
import 'package:cb_prestige_qr/features/scan/data/data_sources/scan_remote_data_source_fake.dart';
|
||||||
|
import 'package:cb_prestige_qr/features/scan/data/repositories/scan_repository_impl.dart';
|
||||||
|
import 'package:cb_prestige_qr/features/scan/domain/repositories/scan_repository.dart';
|
||||||
|
import 'package:cb_prestige_qr/features/scan/domain/use_cases/process_scan_use_case.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
part 'scan_providers.g.dart';
|
||||||
|
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
ScanRemoteDataSource scanRemoteDataSource(Ref ref) {
|
||||||
|
return FakeScanRemoteDataSource();
|
||||||
|
}
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
ScanRepository scanRepository(Ref ref) {
|
||||||
|
return ScanRepositoryImpl(ref.watch(scanRemoteDataSourceProvider));
|
||||||
|
}
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
ProcessScanUseCase processScanUseCase(Ref ref) {
|
||||||
|
return ProcessScanUseCase(ref.watch(scanRepositoryProvider));
|
||||||
|
}
|
||||||
|
|
||||||
147
lib/features/scan/scan_providers.g.dart
Normal file
147
lib/features/scan/scan_providers.g.dart
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'scan_providers.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
|
||||||
|
@ProviderFor(scanRemoteDataSource)
|
||||||
|
final scanRemoteDataSourceProvider = ScanRemoteDataSourceProvider._();
|
||||||
|
|
||||||
|
final class ScanRemoteDataSourceProvider
|
||||||
|
extends
|
||||||
|
$FunctionalProvider<
|
||||||
|
ScanRemoteDataSource,
|
||||||
|
ScanRemoteDataSource,
|
||||||
|
ScanRemoteDataSource
|
||||||
|
>
|
||||||
|
with $Provider<ScanRemoteDataSource> {
|
||||||
|
ScanRemoteDataSourceProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'scanRemoteDataSourceProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$scanRemoteDataSourceHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$ProviderElement<ScanRemoteDataSource> $createElement(
|
||||||
|
$ProviderPointer pointer,
|
||||||
|
) => $ProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
ScanRemoteDataSource create(Ref ref) {
|
||||||
|
return scanRemoteDataSource(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(ScanRemoteDataSource value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<ScanRemoteDataSource>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$scanRemoteDataSourceHash() =>
|
||||||
|
r'f25d69a350b64c4dd0cf8ef124838bb351b5e4a1';
|
||||||
|
|
||||||
|
@ProviderFor(scanRepository)
|
||||||
|
final scanRepositoryProvider = ScanRepositoryProvider._();
|
||||||
|
|
||||||
|
final class ScanRepositoryProvider
|
||||||
|
extends $FunctionalProvider<ScanRepository, ScanRepository, ScanRepository>
|
||||||
|
with $Provider<ScanRepository> {
|
||||||
|
ScanRepositoryProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'scanRepositoryProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$scanRepositoryHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$ProviderElement<ScanRepository> $createElement($ProviderPointer pointer) =>
|
||||||
|
$ProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
ScanRepository create(Ref ref) {
|
||||||
|
return scanRepository(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(ScanRepository value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<ScanRepository>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$scanRepositoryHash() => r'3e719b962dbb1014143710238128234371ef141e';
|
||||||
|
|
||||||
|
@ProviderFor(processScanUseCase)
|
||||||
|
final processScanUseCaseProvider = ProcessScanUseCaseProvider._();
|
||||||
|
|
||||||
|
final class ProcessScanUseCaseProvider
|
||||||
|
extends
|
||||||
|
$FunctionalProvider<
|
||||||
|
ProcessScanUseCase,
|
||||||
|
ProcessScanUseCase,
|
||||||
|
ProcessScanUseCase
|
||||||
|
>
|
||||||
|
with $Provider<ProcessScanUseCase> {
|
||||||
|
ProcessScanUseCaseProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'processScanUseCaseProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$processScanUseCaseHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$ProviderElement<ProcessScanUseCase> $createElement(
|
||||||
|
$ProviderPointer pointer,
|
||||||
|
) => $ProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
ProcessScanUseCase create(Ref ref) {
|
||||||
|
return processScanUseCase(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(ProcessScanUseCase value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<ProcessScanUseCase>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$processScanUseCaseHash() =>
|
||||||
|
r'ce9f6a3d0de27fe0f7f8e7fa1ac9c1f88ad57e43';
|
||||||
@ -1,56 +1,33 @@
|
|||||||
import 'package:cb_prestige_qr/core/storage/local_storage_service.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/auth/data/data_sources/auth_local_data_source.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/auth/domain/entities/user_profile.dart';
|
|
||||||
|
|
||||||
import '../../domain/entities/settings_content.dart';
|
import '../../domain/entities/settings_content.dart';
|
||||||
|
|
||||||
abstract class SettingsLocalDataSource {
|
abstract class SettingsLocalDataSource {
|
||||||
Future<SettingsContent> getSettings();
|
SettingsContent getSettings();
|
||||||
Future<void> setNotificationsEnabled(bool value);
|
void setNotificationsEnabled(bool value);
|
||||||
Future<void> setHapticsEnabled(bool value);
|
void setHapticsEnabled(bool value);
|
||||||
}
|
}
|
||||||
|
|
||||||
class SettingsLocalDataSourceImpl implements SettingsLocalDataSource {
|
class SettingsLocalDataSourceImpl implements SettingsLocalDataSource {
|
||||||
SettingsLocalDataSourceImpl(this._storage, this._authLocalDataSource);
|
SettingsLocalDataSourceImpl();
|
||||||
|
|
||||||
final LocalStorageService _storage;
|
bool _notificationsEnabled = true;
|
||||||
final AuthLocalDataSource _authLocalDataSource;
|
bool _hapticsEnabled = true;
|
||||||
|
|
||||||
static const _notificationsKey = 'settings_notifications_enabled';
|
|
||||||
static const _hapticsKey = 'settings_haptics_enabled';
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<SettingsContent> getSettings() async {
|
SettingsContent getSettings() {
|
||||||
final userProfile = await _authLocalDataSource.getSavedUserProfile();
|
|
||||||
|
|
||||||
return SettingsContent(
|
return SettingsContent(
|
||||||
notificationsEnabled: await _storage.getBool(_notificationsKey) ?? true,
|
notificationsEnabled: _notificationsEnabled,
|
||||||
hapticsEnabled: await _storage.getBool(_hapticsKey) ?? true,
|
hapticsEnabled: _hapticsEnabled,
|
||||||
appVersionLabel: 'v1.0.0',
|
appVersionLabel: 'v1.0.0',
|
||||||
userProfile: userProfile ?? const _FallbackUserProfileFactory().create(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setNotificationsEnabled(bool value) async {
|
void setNotificationsEnabled(bool value) {
|
||||||
await _storage.setBool(_notificationsKey, value);
|
_notificationsEnabled = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setHapticsEnabled(bool value) async {
|
void setHapticsEnabled(bool value) {
|
||||||
await _storage.setBool(_hapticsKey, value);
|
_hapticsEnabled = value;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _FallbackUserProfileFactory {
|
|
||||||
const _FallbackUserProfileFactory();
|
|
||||||
|
|
||||||
UserProfile create() {
|
|
||||||
return const UserProfile(
|
|
||||||
username: 'cashier',
|
|
||||||
displayName: 'Cashier',
|
|
||||||
roleLabel: 'Cashier',
|
|
||||||
branchLabel: 'Prestige Counter',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,15 +8,15 @@ class SettingsRepositoryImpl implements SettingsRepository {
|
|||||||
final SettingsLocalDataSource _localDataSource;
|
final SettingsLocalDataSource _localDataSource;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<SettingsContent> getSettings() => _localDataSource.getSettings();
|
Future<SettingsContent> getSettings() async => _localDataSource.getSettings();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setNotificationsEnabled(bool value) async {
|
Future<void> setNotificationsEnabled(bool value) async {
|
||||||
await _localDataSource.setNotificationsEnabled(value);
|
_localDataSource.setNotificationsEnabled(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setHapticsEnabled(bool value) async {
|
Future<void> setHapticsEnabled(bool value) async {
|
||||||
await _localDataSource.setHapticsEnabled(value);
|
_localDataSource.setHapticsEnabled(value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,15 +1,11 @@
|
|||||||
import 'package:cb_prestige_qr/features/auth/domain/entities/user_profile.dart';
|
|
||||||
|
|
||||||
class SettingsContent {
|
class SettingsContent {
|
||||||
const SettingsContent({
|
const SettingsContent({
|
||||||
required this.notificationsEnabled,
|
required this.notificationsEnabled,
|
||||||
required this.hapticsEnabled,
|
required this.hapticsEnabled,
|
||||||
required this.appVersionLabel,
|
required this.appVersionLabel,
|
||||||
required this.userProfile,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final bool notificationsEnabled;
|
final bool notificationsEnabled;
|
||||||
final bool hapticsEnabled;
|
final bool hapticsEnabled;
|
||||||
final String appVersionLabel;
|
final String appVersionLabel;
|
||||||
final UserProfile userProfile;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import 'package:cb_prestige_qr/features/auth/domain/entities/user_profile.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
@immutable
|
@immutable
|
||||||
@ -7,25 +6,21 @@ class SettingsUiState {
|
|||||||
required this.notificationsEnabled,
|
required this.notificationsEnabled,
|
||||||
required this.hapticsEnabled,
|
required this.hapticsEnabled,
|
||||||
required this.appVersionLabel,
|
required this.appVersionLabel,
|
||||||
required this.userProfile,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
final bool notificationsEnabled;
|
final bool notificationsEnabled;
|
||||||
final bool hapticsEnabled;
|
final bool hapticsEnabled;
|
||||||
final String appVersionLabel;
|
final String appVersionLabel;
|
||||||
final UserProfile userProfile;
|
|
||||||
|
|
||||||
SettingsUiState copyWith({
|
SettingsUiState copyWith({
|
||||||
bool? notificationsEnabled,
|
bool? notificationsEnabled,
|
||||||
bool? hapticsEnabled,
|
bool? hapticsEnabled,
|
||||||
String? appVersionLabel,
|
String? appVersionLabel,
|
||||||
UserProfile? userProfile,
|
|
||||||
}) {
|
}) {
|
||||||
return SettingsUiState(
|
return SettingsUiState(
|
||||||
notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled,
|
notificationsEnabled: notificationsEnabled ?? this.notificationsEnabled,
|
||||||
hapticsEnabled: hapticsEnabled ?? this.hapticsEnabled,
|
hapticsEnabled: hapticsEnabled ?? this.hapticsEnabled,
|
||||||
appVersionLabel: appVersionLabel ?? this.appVersionLabel,
|
appVersionLabel: appVersionLabel ?? this.appVersionLabel,
|
||||||
userProfile: userProfile ?? this.userProfile,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1,5 +1,3 @@
|
|||||||
import 'package:cb_prestige_qr/core/providers/core_providers.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/auth/di/auth_providers.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
import '../../data/data_sources/settings_local_data_source.dart';
|
import '../../data/data_sources/settings_local_data_source.dart';
|
||||||
@ -12,10 +10,7 @@ import '../../domain/use_cases/set_notifications_enabled.dart';
|
|||||||
import 'settings_ui_state.dart';
|
import 'settings_ui_state.dart';
|
||||||
|
|
||||||
final _settingsLocalDataSourceProvider = Provider<SettingsLocalDataSource>(
|
final _settingsLocalDataSourceProvider = Provider<SettingsLocalDataSource>(
|
||||||
(ref) => SettingsLocalDataSourceImpl(
|
(ref) => SettingsLocalDataSourceImpl(),
|
||||||
ref.watch(localStorageServiceProvider),
|
|
||||||
ref.watch(authLocalDataSourceProvider),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final _settingsRepositoryProvider = Provider<SettingsRepository>(
|
final _settingsRepositoryProvider = Provider<SettingsRepository>(
|
||||||
@ -51,21 +46,16 @@ class SettingsViewModel extends AsyncNotifier<SettingsUiState> {
|
|||||||
notificationsEnabled: content.notificationsEnabled,
|
notificationsEnabled: content.notificationsEnabled,
|
||||||
hapticsEnabled: content.hapticsEnabled,
|
hapticsEnabled: content.hapticsEnabled,
|
||||||
appVersionLabel: content.appVersionLabel,
|
appVersionLabel: content.appVersionLabel,
|
||||||
userProfile: content.userProfile,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> toggleNotifications(bool value) async {
|
Future<void> toggleNotifications(bool value) async {
|
||||||
await ref.read(_setNotificationsEnabledProvider)(value);
|
await ref.watch(_setNotificationsEnabledProvider)(value);
|
||||||
state = state.whenData((s) => s.copyWith(notificationsEnabled: value));
|
state = state.whenData((s) => s.copyWith(notificationsEnabled: value));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> toggleHaptics(bool value) async {
|
Future<void> toggleHaptics(bool value) async {
|
||||||
await ref.read(_setHapticsEnabledProvider)(value);
|
await ref.watch(_setHapticsEnabledProvider)(value);
|
||||||
state = state.whenData((s) => s.copyWith(hapticsEnabled: value));
|
state = state.whenData((s) => s.copyWith(hapticsEnabled: value));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> logout() async {
|
|
||||||
await ref.read(logoutUseCaseProvider)();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
132
lib/features/settings/presentation/pages/settings_page.dart
Normal file
132
lib/features/settings/presentation/pages/settings_page.dart
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
|
import '../manager/settings_ui_state.dart';
|
||||||
|
import '../manager/settings_view_model.dart';
|
||||||
|
|
||||||
|
class SettingsPage extends ConsumerWidget {
|
||||||
|
const SettingsPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final viewModel = ref.read(settingsViewModelProvider.notifier);
|
||||||
|
final stateAsync = ref.watch(settingsViewModelProvider);
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: theme.scaffoldBackgroundColor,
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Settings'),
|
||||||
|
backgroundColor: theme.scaffoldBackgroundColor,
|
||||||
|
surfaceTintColor: theme.scaffoldBackgroundColor,
|
||||||
|
elevation: 0,
|
||||||
|
scrolledUnderElevation: 0,
|
||||||
|
),
|
||||||
|
body: stateAsync.when(
|
||||||
|
data: (state) => _SettingsBody(
|
||||||
|
state: state,
|
||||||
|
onNotificationsChanged: viewModel.toggleNotifications,
|
||||||
|
onHapticsChanged: viewModel.toggleHaptics,
|
||||||
|
),
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator()),
|
||||||
|
error: (error, stackTrace) => Center(
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: () => ref.invalidate(settingsViewModelProvider),
|
||||||
|
child: const Text('Retry'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SettingsBody extends StatelessWidget {
|
||||||
|
const _SettingsBody({
|
||||||
|
required this.state,
|
||||||
|
required this.onNotificationsChanged,
|
||||||
|
required this.onHapticsChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
final SettingsUiState state;
|
||||||
|
final ValueChanged<bool> onNotificationsChanged;
|
||||||
|
final ValueChanged<bool> onHapticsChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||||
|
children: [
|
||||||
|
_SectionCard(
|
||||||
|
title: 'Preferences',
|
||||||
|
children: [
|
||||||
|
SwitchListTile.adaptive(
|
||||||
|
value: state.notificationsEnabled,
|
||||||
|
onChanged: onNotificationsChanged,
|
||||||
|
title: const Text('Notifications'),
|
||||||
|
subtitle: const Text('Get updates about scans'),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
SwitchListTile.adaptive(
|
||||||
|
value: state.hapticsEnabled,
|
||||||
|
onChanged: onHapticsChanged,
|
||||||
|
title: const Text('Haptics'),
|
||||||
|
subtitle: const Text('Vibration feedback'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_SectionCard(
|
||||||
|
title: 'About',
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
title: const Text('Version'),
|
||||||
|
subtitle: Text(state.appVersionLabel),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SectionCard extends StatelessWidget {
|
||||||
|
const _SectionCard({required this.title, required this.children});
|
||||||
|
|
||||||
|
final String title;
|
||||||
|
final List<Widget> children;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.5)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.06),
|
||||||
|
blurRadius: 14,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
|
||||||
|
child: Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...children,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,312 +0,0 @@
|
|||||||
import 'package:cb_prestige_qr/app/navigation/main_shell.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/auth/domain/entities/user_profile.dart';
|
|
||||||
import 'package:cb_prestige_qr/features/auth/presentation/views/login_view.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
|
|
||||||
import '../viewmodels/settings_ui_state.dart';
|
|
||||||
import '../viewmodels/settings_view_model.dart';
|
|
||||||
|
|
||||||
class SettingsView extends ConsumerWidget {
|
|
||||||
const SettingsView({super.key});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final viewModel = ref.read(settingsViewModelProvider.notifier);
|
|
||||||
final stateAsync = ref.watch(settingsViewModelProvider);
|
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: theme.scaffoldBackgroundColor,
|
|
||||||
appBar: AppBar(
|
|
||||||
title: const Text('Settings'),
|
|
||||||
backgroundColor: theme.scaffoldBackgroundColor,
|
|
||||||
surfaceTintColor: theme.scaffoldBackgroundColor,
|
|
||||||
elevation: 0,
|
|
||||||
scrolledUnderElevation: 0,
|
|
||||||
),
|
|
||||||
body: stateAsync.when(
|
|
||||||
data: (state) => _SettingsBody(
|
|
||||||
state: state,
|
|
||||||
onNotificationsChanged: viewModel.toggleNotifications,
|
|
||||||
onHapticsChanged: viewModel.toggleHaptics,
|
|
||||||
onLogout: () {
|
|
||||||
viewModel.logout().then((_) {
|
|
||||||
if (!context.mounted) return;
|
|
||||||
ref.read(navIndexProvider.notifier).setIndex(0);
|
|
||||||
Navigator.of(context).pushAndRemoveUntil(
|
|
||||||
MaterialPageRoute(builder: (_) => const LoginView()),
|
|
||||||
(route) => false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
|
||||||
error: (error, stackTrace) => Center(
|
|
||||||
child: TextButton(
|
|
||||||
onPressed: () => ref.invalidate(settingsViewModelProvider),
|
|
||||||
child: const Text('Retry'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SettingsBody extends StatelessWidget {
|
|
||||||
const _SettingsBody({
|
|
||||||
required this.state,
|
|
||||||
required this.onNotificationsChanged,
|
|
||||||
required this.onHapticsChanged,
|
|
||||||
required this.onLogout,
|
|
||||||
});
|
|
||||||
|
|
||||||
final SettingsUiState state;
|
|
||||||
final ValueChanged<bool> onNotificationsChanged;
|
|
||||||
final ValueChanged<bool> onHapticsChanged;
|
|
||||||
final VoidCallback onLogout;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return ListView(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
|
||||||
children: [
|
|
||||||
_ProfileCard(profile: state.userProfile),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_SectionCard(
|
|
||||||
title: 'Preferences',
|
|
||||||
children: [
|
|
||||||
SwitchListTile.adaptive(
|
|
||||||
value: state.notificationsEnabled,
|
|
||||||
onChanged: onNotificationsChanged,
|
|
||||||
title: const Text('Notifications'),
|
|
||||||
subtitle: const Text('Get updates about scans'),
|
|
||||||
),
|
|
||||||
const Divider(height: 1),
|
|
||||||
SwitchListTile.adaptive(
|
|
||||||
value: state.hapticsEnabled,
|
|
||||||
onChanged: onHapticsChanged,
|
|
||||||
title: const Text('Haptics'),
|
|
||||||
subtitle: const Text('Vibration feedback'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_SectionCard(
|
|
||||||
title: 'About',
|
|
||||||
children: [
|
|
||||||
ListTile(
|
|
||||||
title: const Text('Version'),
|
|
||||||
subtitle: Text(state.appVersionLabel),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
_LogoutButton(onPressed: onLogout),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ProfileCard extends StatelessWidget {
|
|
||||||
const _ProfileCard({required this.profile});
|
|
||||||
|
|
||||||
final UserProfile profile;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
final textTheme = Theme.of(context).textTheme;
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surface,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
border: Border.all(
|
|
||||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
|
||||||
),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withValues(alpha: 0.06),
|
|
||||||
blurRadius: 14,
|
|
||||||
offset: const Offset(0, 6),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 54,
|
|
||||||
height: 54,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.primaryContainer,
|
|
||||||
borderRadius: BorderRadius.circular(18),
|
|
||||||
),
|
|
||||||
alignment: Alignment.center,
|
|
||||||
child: Text(
|
|
||||||
profile.initials,
|
|
||||||
style: textTheme.titleLarge?.copyWith(
|
|
||||||
color: colorScheme.onPrimaryContainer,
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 14),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
profile.displayName,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 3),
|
|
||||||
Text(
|
|
||||||
'@${profile.username}',
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: textTheme.bodySmall?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
|
||||||
Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: [
|
|
||||||
_ProfileChip(
|
|
||||||
icon: Icons.badge_rounded,
|
|
||||||
label: profile.roleLabel,
|
|
||||||
),
|
|
||||||
_ProfileChip(
|
|
||||||
icon: Icons.store_rounded,
|
|
||||||
label: profile.branchLabel,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ProfileChip extends StatelessWidget {
|
|
||||||
const _ProfileChip({required this.icon, required this.label});
|
|
||||||
|
|
||||||
final IconData icon;
|
|
||||||
final String label;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
final textTheme = Theme.of(context).textTheme;
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.55),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Icon(icon, size: 15, color: colorScheme.onSurfaceVariant),
|
|
||||||
const SizedBox(width: 5),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: textTheme.labelMedium?.copyWith(
|
|
||||||
color: colorScheme.onSurfaceVariant,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _LogoutButton extends StatelessWidget {
|
|
||||||
const _LogoutButton({required this.onPressed});
|
|
||||||
|
|
||||||
final VoidCallback onPressed;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return SizedBox(
|
|
||||||
width: double.infinity,
|
|
||||||
child: FilledButton.icon(
|
|
||||||
onPressed: onPressed,
|
|
||||||
icon: const Icon(Icons.logout_rounded),
|
|
||||||
label: const Text(
|
|
||||||
'Logout',
|
|
||||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor: colorScheme.primary,
|
|
||||||
foregroundColor: colorScheme.onPrimary,
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(18),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SectionCard extends StatelessWidget {
|
|
||||||
const _SectionCard({required this.title, required this.children});
|
|
||||||
|
|
||||||
final String title;
|
|
||||||
final List<Widget> children;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: colorScheme.surface,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
border: Border.all(
|
|
||||||
color: colorScheme.outlineVariant.withValues(alpha: 0.5),
|
|
||||||
),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withValues(alpha: 0.06),
|
|
||||||
blurRadius: 14,
|
|
||||||
offset: const Offset(0, 6),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 8),
|
|
||||||
child: Text(
|
|
||||||
title,
|
|
||||||
style: Theme.of(
|
|
||||||
context,
|
|
||||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
...children,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:cb_prestige_qr/app/navigation/main_shell.dart';
|
import 'package:cb_prestige_qr/core/utils/MainShell.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class SplashScreen extends StatefulWidget {
|
class SplashScreen extends StatefulWidget {
|
||||||
@ -26,9 +26,9 @@ class _SplashScreenState extends State<SplashScreen> {
|
|||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_timer = Timer(widget.duration, () {
|
_timer = Timer(widget.duration, () {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.of(
|
Navigator.of(context).pushReplacement(
|
||||||
context,
|
MaterialPageRoute(builder: (_) => const MainShell()),
|
||||||
).pushReplacement(MaterialPageRoute(builder: (_) => const MainShell()));
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -43,8 +43,12 @@ class _SplashScreenState extends State<SplashScreen> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: SizedBox.expand(
|
body: SizedBox.expand(
|
||||||
child: Image.asset(widget.imageAssetPath, fit: BoxFit.cover),
|
child: Image.asset(
|
||||||
|
widget.imageAssetPath,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import 'package:cb_prestige_qr/app/app.dart';
|
import 'package:cb_prestige_qr/core/utils/MainShell.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
@ -6,3 +6,29 @@ void main() {
|
|||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
runApp(const ProviderScope(child: MyApp()));
|
runApp(const ProviderScope(child: MyApp()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class MyApp extends StatelessWidget {
|
||||||
|
const MyApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MaterialApp(
|
||||||
|
title: 'CB Prestige Banking',
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
theme: ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
colorScheme: ColorScheme.fromSeed(
|
||||||
|
seedColor: const Color(0xff896e4b),
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
),
|
||||||
|
scaffoldBackgroundColor: const Color(0xff25262b),
|
||||||
|
appBarTheme: const AppBarTheme(
|
||||||
|
backgroundColor: Color(0xff896e4b),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
home: const MainShell(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
122
pubspec.lock
122
pubspec.lock
@ -217,22 +217,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
dio:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: dio
|
|
||||||
sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "5.9.2"
|
|
||||||
dio_web_adapter:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: dio_web_adapter
|
|
||||||
sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.1.2"
|
|
||||||
fake_async:
|
fake_async:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -303,7 +287,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.3.1"
|
version: "3.3.1"
|
||||||
flutter_svg:
|
flutter_svg:
|
||||||
dependency: "direct main"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: flutter_svg
|
name: flutter_svg
|
||||||
sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9"
|
sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9"
|
||||||
@ -416,14 +400,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.11.0"
|
version: "4.11.0"
|
||||||
jwt_decoder:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: jwt_decoder
|
|
||||||
sha256: "54774aebf83f2923b99e6416b4ea915d47af3bde56884eb622de85feabbc559f"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.0.1"
|
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -544,30 +520,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.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"
|
|
||||||
petitparser:
|
petitparser:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -576,14 +528,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "7.0.2"
|
version: "7.0.2"
|
||||||
platform:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: platform
|
|
||||||
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.1.6"
|
|
||||||
plugin_platform_interface:
|
plugin_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -656,62 +600,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.3"
|
version: "4.0.3"
|
||||||
shared_preferences:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: shared_preferences
|
|
||||||
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.5.5"
|
|
||||||
shared_preferences_android:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: shared_preferences_android
|
|
||||||
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.4.23"
|
|
||||||
shared_preferences_foundation:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: shared_preferences_foundation
|
|
||||||
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.5.6"
|
|
||||||
shared_preferences_linux:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: shared_preferences_linux
|
|
||||||
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.4.1"
|
|
||||||
shared_preferences_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: shared_preferences_platform_interface
|
|
||||||
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.4.2"
|
|
||||||
shared_preferences_web:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: shared_preferences_web
|
|
||||||
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.4.3"
|
|
||||||
shared_preferences_windows:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: shared_preferences_windows
|
|
||||||
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "2.4.1"
|
|
||||||
shelf:
|
shelf:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@ -949,14 +837,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.1"
|
version: "1.2.1"
|
||||||
xdg_directories:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: xdg_directories
|
|
||||||
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.1.0"
|
|
||||||
xml:
|
xml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -40,10 +40,6 @@ dependencies:
|
|||||||
riverpod_annotation: ^4.0.2
|
riverpod_annotation: ^4.0.2
|
||||||
carousel_slider: ^5.1.2
|
carousel_slider: ^5.1.2
|
||||||
mobile_scanner: ^7.2.0
|
mobile_scanner: ^7.2.0
|
||||||
shared_preferences: ^2.5.5
|
|
||||||
jwt_decoder: ^2.0.1
|
|
||||||
dio: ^5.9.0
|
|
||||||
flutter_svg: ^2.2.4
|
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
@ -57,6 +53,7 @@ dev_dependencies:
|
|||||||
flutter_lints: ^6.0.0
|
flutter_lints: ^6.0.0
|
||||||
riverpod_generator: ^4.0.3
|
riverpod_generator: ^4.0.3
|
||||||
build_runner: ^2.13.1
|
build_runner: ^2.13.1
|
||||||
|
flutter_svg: ^2.2.4
|
||||||
flutter_native_splash: 2.4.7
|
flutter_native_splash: 2.4.7
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,21 +1,30 @@
|
|||||||
// This is a basic Flutter widget test.
|
// This is a basic Flutter widget test.
|
||||||
//
|
//
|
||||||
// To perform an interaction with a widget in your test, use the WidgetTester
|
// To perform an interaction with a widget in your test, use the WidgetTester
|
||||||
// utility in the flutter_test package. For example, you can send tap and scroll
|
// utility in the flutter_test package. For example, you can send tap and scroll
|
||||||
// gestures. You can also use WidgetTester to find child widgets in the widget
|
// gestures. You can also use WidgetTester to find child widgets in the widget
|
||||||
// tree, read text, and verify that the values of widget properties are correct.
|
// tree, read text, and verify that the values of widget properties are correct.
|
||||||
|
|
||||||
import 'package:cb_prestige_qr/app/app.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
|
import 'package:cb_prestige_qr/main.dart';
|
||||||
void main() {
|
|
||||||
testWidgets('shows login form fields', (WidgetTester tester) async {
|
void main() {
|
||||||
await tester.pumpWidget(const ProviderScope(child: MyApp()));
|
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||||
|
// Build our app and trigger a frame.
|
||||||
expect(find.text('Secure Login'), findsOneWidget);
|
await tester.pumpWidget(const MyApp());
|
||||||
expect(find.text('Username'), findsOneWidget);
|
|
||||||
expect(find.text('Password'), findsOneWidget);
|
// Verify that our counter starts at 0.
|
||||||
expect(find.text('Sign In'), findsOneWidget);
|
expect(find.text('0'), findsOneWidget);
|
||||||
});
|
expect(find.text('1'), findsNothing);
|
||||||
}
|
|
||||||
|
// Tap the '+' icon and trigger a frame.
|
||||||
|
await tester.tap(find.byIcon(Icons.add));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Verify that our counter has incremented.
|
||||||
|
expect(find.text('0'), findsNothing);
|
||||||
|
expect(find.text('1'), findsOneWidget);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user