Compare commits

..

No commits in common. "dev" and "main" have entirely different histories.
dev ... main

84 changed files with 1327 additions and 3037 deletions

View File

@ -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
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() { 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()
}
}
}
}

View File

@ -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:

View File

@ -3,8 +3,6 @@ import UIKit
@main @main
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate { @objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
private let deviceChannelName = "cb_prestige_qr/device"
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
@ -14,29 +12,5 @@ import UIKit
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)
}
}
} }
} }

View File

@ -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(),
);
}
}

View File

@ -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),
),
),
);
}
}

View File

@ -1,5 +0,0 @@
class AppConfig {
const AppConfig._();
static const apiBaseUrl = 'http://192.168.100.41:3000';
}

View File

@ -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',
},
),
);
}
}

View File

@ -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

View File

@ -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());

View File

@ -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';
}
}

View File

@ -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);
}
}

View File

@ -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),
), ),
), ),
], ],

View File

@ -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 {

View File

@ -1,15 +1,15 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class CenterNavButton extends StatelessWidget { class CenterNavButton extends StatelessWidget {
final bool isActive;
final VoidCallback onTap;
const CenterNavButton({ const CenterNavButton({
super.key, super.key,
required this.isActive, required this.isActive,
required this.onTap, required this.onTap,
}); });
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);

View File

@ -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);

View File

@ -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',
}; };
} }

View File

@ -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;

View File

@ -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,
); );

View 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')),
],
),
),
);
}
}

View File

@ -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),
),
],
);
}

View File

@ -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)}';
}
}

View File

@ -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)}';
}
}

View File

@ -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,
};
}
}

View File

@ -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;
}

View File

@ -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();
}

View File

@ -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)),
);

View File

@ -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;
}

View File

@ -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();
}
}

View File

@ -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();
}

View File

@ -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();
}

View File

@ -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);
}
}

View File

@ -1,9 +0,0 @@
import '../repositories/auth_repository.dart';
class LogoutUseCase {
const LogoutUseCase(this._repository);
final AuthRepository _repository;
Future<void> call() => _repository.logout();
}

View File

@ -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?,
);
}
}

View File

@ -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;
}
}
}

View File

@ -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,
),
);
}
}

View File

@ -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) {

View File

@ -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,

View File

@ -28,3 +28,4 @@ class HomeLocalDataSourceImpl implements HomeLocalDataSource {
); );
} }
} }

View File

@ -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,
});
} }

View File

@ -18,3 +18,4 @@ class HomeRepositoryImpl implements HomeRepository {
); );
} }
} }

View File

@ -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;
} }

View File

@ -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;
} }

View File

@ -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});
} }

View File

@ -10,3 +10,4 @@ class GetHomeContent {
return _repository.getHomeContent(recentLimit: recentLimit); return _repository.getHomeContent(recentLimit: recentLimit);
} }
} }

View File

@ -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
}
} }

View File

@ -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 {
@ -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(

View File

@ -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

View File

@ -164,3 +164,4 @@ class _MiniStat extends StatelessWidget {
); );
} }
} }

View File

View 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,
});
} }

View File

@ -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(),
}; };
} }
} }

View File

@ -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',
};
}
}

View File

@ -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);
} }
} }

View File

@ -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();
} }
} }

View File

@ -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(', ')}.',
);
}
}

View File

@ -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;
} }

View File

@ -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);
} }

View File

@ -10,3 +10,4 @@ class ProcessScanUseCase {
return _repository.processRawValue(rawValue); return _repository.processRawValue(rawValue);
} }
} }

View 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);
}
}

View File

@ -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);
}
}

View File

@ -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: [

View File

@ -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)),
);

View File

@ -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,
);
}
}

View File

@ -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;
}
}

View 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));
}

View 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';

View File

@ -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',
);
} }
} }

View File

@ -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);
} }
} }

View File

@ -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;
} }

View File

@ -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,
); );
} }
} }

View File

@ -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)();
}
} }

View 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,
],
),
);
}
}

View File

@ -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,
],
),
);
}
}

View File

@ -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,
),
), ),
); );
} }
} }

View File

@ -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(),
);
}
}

View File

@ -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:

View File

@ -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

View File

@ -5,17 +5,26 @@
// 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() { void main() {
testWidgets('shows login form fields', (WidgetTester tester) async { testWidgets('Counter increments smoke test', (WidgetTester tester) async {
await tester.pumpWidget(const ProviderScope(child: MyApp())); // Build our app and trigger a frame.
await tester.pumpWidget(const MyApp());
expect(find.text('Secure Login'), findsOneWidget); // Verify that our counter starts at 0.
expect(find.text('Username'), findsOneWidget); expect(find.text('0'), findsOneWidget);
expect(find.text('Password'), findsOneWidget); expect(find.text('1'), findsNothing);
expect(find.text('Sign In'), findsOneWidget);
// 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);
}); });
} }