feat: Implement a new application theme with custom colors and Google Fonts, applying it to the notifications screen and other core UI.

This commit is contained in:
Suherdy Yacob 2026-03-21 16:30:12 +07:00
parent f9478a47c6
commit 0fe07fd555
7 changed files with 387 additions and 123 deletions

View File

@ -3,11 +3,11 @@ import 'package:flutter/material.dart';
import 'package:workmanager/workmanager.dart'; import 'package:workmanager/workmanager.dart';
import 'screens/login_screen.dart'; import 'screens/login_screen.dart';
import 'services/background_service.dart'; import 'services/background_service.dart';
import 'theme/app_theme.dart';
void main() { void main() {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// Workmanager background tasks are only supported on physical iOS and Android devices.
if (Platform.isAndroid || Platform.isIOS) { if (Platform.isAndroid || Platform.isIOS) {
Workmanager().initialize( Workmanager().initialize(
callbackDispatcher, callbackDispatcher,
@ -30,12 +30,9 @@ class OdooLoyaltyApp extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MaterialApp( return MaterialApp(
title: 'Odoo Loyalty App', title: 'Mie Mapan Loyalty App',
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
theme: ThemeData( theme: AppTheme.lightTheme,
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const LoginScreen(), home: const LoginScreen(),
); );
} }

View File

@ -4,6 +4,7 @@ import 'package:odoo_rpc/odoo_rpc.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../services/odoo_service.dart'; import '../services/odoo_service.dart';
import 'loyalty_dashboard.dart'; import 'loyalty_dashboard.dart';
import '../theme/app_theme.dart';
class LoginScreen extends StatefulWidget { class LoginScreen extends StatefulWidget {
const LoginScreen({super.key}); const LoginScreen({super.key});
@ -49,21 +50,13 @@ class _LoginScreenState extends State<LoginScreen> {
if (mounted) { if (mounted) {
Navigator.pushReplacement( Navigator.pushReplacement(
context, context,
MaterialPageRoute( MaterialPageRoute(builder: (_) => LoyaltyDashboard(partnerId: session.partnerId)),
builder: (_) => LoyaltyDashboard(
partnerId: session.partnerId,
),
),
); );
} }
} on OdooException catch (e) { } on OdooException catch (e) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Login failed: ${e.message}')));
SnackBar(content: Text('Login failed: ${e.message}')),
);
} catch (e) { } catch (e) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error: $e')));
SnackBar(content: Text('Error: $e')),
);
} finally { } finally {
if (mounted) setState(() => _isLoading = false); if (mounted) setState(() => _isLoading = false);
} }
@ -72,40 +65,57 @@ class _LoginScreenState extends State<LoginScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Login to Odoo')), body: SafeArea(
body: Padding( child: SingleChildScrollView(
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 48.0),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.stretch,
crossAxisAlignment: CrossAxisAlignment.stretch, children: [
children: [ const SizedBox(height: 64),
TextField( Text(
controller: _usernameController, 'Mie Mapan\nMembresia', // Editorial high-end entry
decoration: const InputDecoration( style: Theme.of(context).textTheme.displayMedium?.copyWith(
labelText: 'Email / Username', color: AppTheme.primary,
border: OutlineInputBorder(), ),
), ),
), const SizedBox(height: 12),
const SizedBox(height: 12), Text(
TextField( 'Sign in to access your culinary loyalty tier and discover exclusive offers.',
controller: _passwordController, style: Theme.of(context).textTheme.bodyLarge,
decoration: const InputDecoration(
labelText: 'Password',
border: OutlineInputBorder(),
), ),
obscureText: true, const SizedBox(height: 56),
), TextField(
const SizedBox(height: 24), controller: _usernameController,
SizedBox( decoration: const InputDecoration(labelText: 'Email / Username'),
height: 50, ),
child: _isLoading const SizedBox(height: 20),
? const Center(child: CircularProgressIndicator()) TextField(
: ElevatedButton( controller: _passwordController,
onPressed: _login, decoration: const InputDecoration(labelText: 'Password'),
child: const Text('Login', style: TextStyle(fontSize: 18)), obscureText: true,
), ),
), const SizedBox(height: 48),
], SizedBox(
height: 56,
child: _isLoading
? const Center(child: CircularProgressIndicator())
: Container(
decoration: BoxDecoration(
gradient: AppTheme.primaryGradient,
borderRadius: BorderRadius.circular(12),
),
child: ElevatedButton(
onPressed: _login,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.transparent,
shadowColor: Colors.transparent,
),
child: const Text('Access Membership', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
),
),
),
],
),
), ),
), ),
); );

View File

@ -1,9 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../services/odoo_service.dart'; import '../services/odoo_service.dart';
import '../theme/app_theme.dart';
import 'notifications_screen.dart';
class LoyaltyDashboard extends StatefulWidget { class LoyaltyDashboard extends StatefulWidget {
final int partnerId; final int partnerId;
const LoyaltyDashboard({super.key, required this.partnerId}); const LoyaltyDashboard({super.key, required this.partnerId});
@override @override
@ -28,10 +29,10 @@ class _LoyaltyDashboardState extends State<LoyaltyDashboard> {
_isLoading = false; _isLoading = false;
}); });
} catch (e) { } catch (e) {
setState(() => _isLoading = false); if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( setState(() => _isLoading = false);
SnackBar(content: Text('Error loading loyalty cards: $e')), ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error loading loyalty cards: $e')));
); }
} }
} }
@ -39,72 +40,99 @@ class _LoyaltyDashboardState extends State<LoyaltyDashboard> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('My Loyalty Programs'), title: const Text('My Rewards'),
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.refresh), icon: const Icon(Icons.notifications_outlined),
onPressed: () { onPressed: () => Navigator.push(context, MaterialPageRoute(builder: (_) => const NotificationsScreen())),
setState(() => _isLoading = true); ),
_fetchLoyaltyData(); const SizedBox(width: 8),
},
)
], ],
), ),
body: _isLoading body: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _loyaltyCards.isEmpty : RefreshIndicator(
? const Center( onRefresh: _fetchLoyaltyData,
child: Text( child: _loyaltyCards.isEmpty
'No loyalty cards found.', ? Center(
style: TextStyle(fontSize: 18), child: Text(
), 'No active rewards yet.',
) style: Theme.of(context).textTheme.titleLarge,
: ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: _loyaltyCards.length,
itemBuilder: (context, index) {
final card = _loyaltyCards[index];
return Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
), ),
margin: const EdgeInsets.symmetric(vertical: 8.0), )
child: Padding( : ListView.builder(
padding: const EdgeInsets.all(12.0), padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 32.0),
child: Column( itemCount: _loyaltyCards.length,
crossAxisAlignment: CrossAxisAlignment.start, itemBuilder: (context, index) {
children: [ final card = _loyaltyCards[index];
Text( return Container(
'${card['program_id']?[1] ?? 'Loyalty Program'}', margin: const EdgeInsets.only(bottom: 32),
style: const TextStyle( padding: const EdgeInsets.all(24),
fontSize: 18, decoration: BoxDecoration(
fontWeight: FontWeight.bold, color: AppTheme.surfaceContainerLow,
), borderRadius: BorderRadius.circular(24),
), boxShadow: [
const SizedBox(height: 8), BoxShadow(
Text('Code: ${card['code'] ?? 'N/A'}'), color: AppTheme.onSurface.withOpacity(0.06),
const SizedBox(height: 8), blurRadius: 24,
Row( offset: const Offset(0, 8),
mainAxisAlignment: MainAxisAlignment.spaceBetween, )
children: [ ]
const Text('Points Available:'), ),
Text( child: Column(
'${card['points'] ?? 0} pts', crossAxisAlignment: CrossAxisAlignment.start,
style: const TextStyle( children: [
fontWeight: FontWeight.bold, Row(
fontSize: 20, mainAxisAlignment: MainAxisAlignment.spaceBetween,
color: Colors.green, children: [
Expanded(
child: Text(
'${card['program_id']?[1] ?? 'Loyalty Program'}',
style: Theme.of(context).textTheme.titleLarge,
softWrap: true,
),
), ),
), const SizedBox(width: 16),
], Container(
), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
], decoration: BoxDecoration(
), color: AppTheme.secondaryContainer,
), borderRadius: BorderRadius.circular(24),
); ),
}, child: Text(
), 'Gold Member',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: AppTheme.onSecondaryContainer,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 32),
Text('Membership Code', style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 4),
Text('${card['code'] ?? 'N/A'}', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('Available Points', style: Theme.of(context).textTheme.bodyMedium),
Text(
'${card['points'] ?? 0}',
style: Theme.of(context).textTheme.displayMedium?.copyWith(
color: AppTheme.primary,
),
),
],
),
],
),
);
},
),
),
); );
} }
} }

View File

@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:odoo_rpc/odoo_rpc.dart';
import '../services/odoo_service.dart'; import '../services/odoo_service.dart';
import '../theme/app_theme.dart';
class NotificationsScreen extends StatefulWidget { class NotificationsScreen extends StatefulWidget {
const NotificationsScreen({super.key}); const NotificationsScreen({super.key});
@ -42,9 +44,7 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
setState(() => _isLoading = false); setState(() => _isLoading = false);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('Error loading notifications: $e')));
SnackBar(content: Text('Error loading notifications: $e')),
);
} }
} }
} }
@ -52,23 +52,49 @@ class _NotificationsScreenState extends State<NotificationsScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Promo Notifications')), appBar: AppBar(title: const Text('Notifications')),
body: _isLoading body: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
: _notifications.isEmpty : _notifications.isEmpty
? const Center(child: Text('No new notifications.', style: TextStyle(fontSize: 16))) ? const Center(child: Text('No new promos.', style: TextStyle(fontSize: 16)))
: ListView.builder( : ListView.builder(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
itemCount: _notifications.length, itemCount: _notifications.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final notif = _notifications[index]; final notif = _notifications[index];
return Card( return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: AppTheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppTheme.onSurface.withOpacity(0.04),
blurRadius: 16,
offset: const Offset(0, 4),
)
]
),
child: ListTile( child: ListTile(
leading: const Icon(Icons.campaign, color: Colors.amber, size: 36), contentPadding: const EdgeInsets.all(16),
title: Text(notif['title'] ?? 'Notice', style: const TextStyle(fontWeight: FontWeight.bold)), leading: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppTheme.primaryContainer.withOpacity(0.1),
shape: BoxShape.circle,
),
child: const Icon(Icons.star, color: AppTheme.primary),
),
title: Text(
notif['title'] ?? 'Notice',
style: Theme.of(context).textTheme.titleMedium,
),
subtitle: Padding( subtitle: Padding(
padding: const EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),
child: Text(notif['body'] ?? ''), child: Text(
notif['body'] ?? '',
style: Theme.of(context).textTheme.bodyMedium,
),
), ),
), ),
); );

106
lib/theme/app_theme.dart Normal file
View File

@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
class AppTheme {
// Mapan Core Tokens
static const Color primary = Color(0xFFB20000);
static const Color primaryContainer = Color(0xFFE00101);
static const Color secondary = Color(0xFF825500);
static const Color secondaryContainer = Color(0xFFFEB23D);
static const Color onSecondaryContainer = Color(0xFF6E4700);
// Surface Hierarchy
static const Color surface = Color(0xFFFCF9F8);
static const Color surfaceContainer = Color(0xFFF0EDED);
static const Color surfaceContainerLow = Color(0xFFF6F3F2);
static const Color surfaceContainerHighest = Color(0xFFE5E2E1);
// Text & On-Colors
static const Color onSurface = Color(0xFF1C1B1B);
static const Color onSurfaceVariant = Color(0xFF5E3F3A);
static const Color onPrimary = Colors.white;
/// The Signature 135-degree CTA Gradient for main buttons.
static const LinearGradient primaryGradient = LinearGradient(
colors: [primary, primaryContainer],
begin: Alignment.topLeft,
end: Alignment.bottomRight, // Approximation of 135 degrees
);
static ThemeData get lightTheme {
final baseTheme = ThemeData.light();
return ThemeData(
useMaterial3: true,
scaffoldBackgroundColor: surface,
colorScheme: const ColorScheme.light(
primary: primary,
primaryContainer: primaryContainer,
secondary: secondary,
secondaryContainer: secondaryContainer,
onSecondaryContainer: onSecondaryContainer,
surface: surface,
onSurface: onSurface,
onSurfaceVariant: onSurfaceVariant,
onPrimary: onPrimary,
error: Color(0xFFBA1A1A),
),
textTheme: baseTheme.textTheme.copyWith(
displayLarge: GoogleFonts.plusJakartaSans(color: onSurface, fontWeight: FontWeight.bold, letterSpacing: -0.5),
displayMedium: GoogleFonts.plusJakartaSans(color: onSurface, fontWeight: FontWeight.bold),
displaySmall: GoogleFonts.plusJakartaSans(color: onSurface, fontWeight: FontWeight.bold),
headlineMedium: GoogleFonts.plusJakartaSans(color: onSurface, fontWeight: FontWeight.bold),
titleLarge: GoogleFonts.beVietnamPro(color: onSurface, fontWeight: FontWeight.w600),
titleMedium: GoogleFonts.beVietnamPro(color: onSurface, fontWeight: FontWeight.w600),
titleSmall: GoogleFonts.beVietnamPro(color: onSurface, fontWeight: FontWeight.w500),
bodyLarge: GoogleFonts.beVietnamPro(color: onSurfaceVariant),
bodyMedium: GoogleFonts.beVietnamPro(color: onSurfaceVariant),
bodySmall: GoogleFonts.beVietnamPro(color: onSurfaceVariant),
labelLarge: GoogleFonts.beVietnamPro(color: onSurfaceVariant),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
foregroundColor: onPrimary,
backgroundColor: primaryContainer, // Fallback if no gradient is used
elevation: 0,
),
),
cardTheme: CardThemeData(
color: surfaceContainerLow,
elevation: 0, // Depth created via tonal shifts
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
margin: EdgeInsets.zero,
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: surfaceContainerHighest,
// Using "Ghost Border" logic at 15% opacity
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0x26946E68)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: Color(0x26946E68)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: primary.withOpacity(0.4), width: 2),
),
labelStyle: const TextStyle(color: onSurfaceVariant),
),
appBarTheme: AppBarTheme(
backgroundColor: surface,
foregroundColor: onSurface,
elevation: 0,
surfaceTintColor: Colors.transparent,
titleTextStyle: GoogleFonts.plusJakartaSans(
color: onSurface,
fontSize: 20,
fontWeight: FontWeight.bold
),
),
);
}
}

View File

@ -49,6 +49,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.0.0"
collection: collection:
dependency: transitive dependency: transitive
description: description:
@ -160,6 +168,30 @@ packages:
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" version: "0.0.0"
glob:
dependency: transitive
description:
name: glob
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
url: "https://pub.dev"
source: hosted
version: "2.1.3"
google_fonts:
dependency: "direct main"
description:
name: google_fonts
sha256: db9df7a5898d894eeda4c78143f35c30a243558be439518972366880b80bf88e
url: "https://pub.dev"
source: hosted
version: "8.0.2"
hooks:
dependency: transitive
description:
name: hooks
sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388
url: "https://pub.dev"
source: hosted
version: "1.0.2"
http: http:
dependency: transitive dependency: transitive
description: description:
@ -208,6 +240,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.1.0" version: "6.1.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher: matcher:
dependency: transitive dependency: transitive
description: description:
@ -232,6 +272,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.17.0" version: "1.17.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
url: "https://pub.dev"
source: hosted
version: "0.17.6"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev"
source: hosted
version: "9.3.0"
odoo_rpc: odoo_rpc:
dependency: "direct main" dependency: "direct main"
description: description:
@ -248,6 +304,30 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
path_provider:
dependency: transitive
description:
name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"
url: "https://pub.dev"
source: hosted
version: "2.1.5"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e
url: "https://pub.dev"
source: hosted
version: "2.2.22"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.6.0"
path_provider_linux: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
@ -296,6 +376,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
shared_preferences: shared_preferences:
dependency: "direct main" dependency: "direct main"
description: description:
@ -493,6 +581,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.6.1" version: "6.6.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.dev"
source: hosted
version: "3.1.3"
sdks: sdks:
dart: ">=3.11.3 <4.0.0" dart: ">=3.11.3 <4.0.0"
flutter: ">=3.38.1" flutter: ">=3.38.4"

View File

@ -38,6 +38,7 @@ dependencies:
workmanager: ^0.9.0+3 workmanager: ^0.9.0+3
flutter_local_notifications: ^21.0.0 flutter_local_notifications: ^21.0.0
shared_preferences: ^2.5.4 shared_preferences: ^2.5.4
google_fonts: ^8.0.2
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: