dev #1

Merged
aefril merged 108 commits from dev into main 2026-05-15 05:52:26 +00:00
13 changed files with 232 additions and 47 deletions
Showing only changes of commit 9b6e9c591d - Show all commits

View File

@ -3,6 +3,7 @@ plugins {
id("kotlin-android") id("kotlin-android")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin") id("dev.flutter.flutter-gradle-plugin")
id("com.google.gms.google-services")
} }
android { android {
@ -11,6 +12,7 @@ android {
ndkVersion = "27.0.12077973" ndkVersion = "27.0.12077973"
compileOptions { compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11
} }
@ -24,7 +26,7 @@ android {
applicationId = "com.apskel.enaklo_owner" applicationId = "com.apskel.enaklo_owner"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = 21
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
@ -42,3 +44,7 @@ android {
flutter { flutter {
source = "../.." source = "../.."
} }
dependencies {
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4")
}

View File

@ -7,6 +7,10 @@
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/> <uses-permission android:name="android.permission.READ_MEDIA_AUDIO"/>
<!-- FCM: required for POST_NOTIFICATIONS on Android 13+ --> <!-- FCM: required for POST_NOTIFICATIONS on Android 13+ -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<!-- FCM: allow background processing after device reboot -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<!-- FCM: allow wake lock for background message processing -->
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<application <application
android:label="Enaklo Owner" android:label="Enaklo Owner"
@ -48,7 +52,21 @@
<!-- FCM: default notification icon --> <!-- FCM: default notification icon -->
<meta-data <meta-data
android:name="com.google.firebase.messaging.default_notification_icon" android:name="com.google.firebase.messaging.default_notification_icon"
android:resource="@mipmap/launcher_icon" /> android:resource="@drawable/ic_notification" />
<!-- FCM: default notification color -->
<meta-data
android:name="com.google.firebase.messaging.default_notification_color"
android:resource="@color/notification_color" />
<!-- FCM: background message handler service -->
<service
android:name="com.google.firebase.messaging.FirebaseMessagingService"
android:exported="false">
<intent-filter android:priority="-500">
<action android:name="com.google.firebase.MESSAGING_EVENT"/>
</intent-filter>
</service>
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@ -1,4 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="ic_launcher_background">#ffffff</color> <color name="ic_launcher_background">#ffffff</color>
<!-- FCM: notification accent color -->
<color name="notification_color">#FF6B35</color>
</resources> </resources>

View File

@ -20,6 +20,7 @@ plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.7.3" apply false id("com.android.application") version "8.7.3" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false id("org.jetbrains.kotlin.android") version "2.1.0" apply false
id("com.google.gms.google-services") version "4.4.2" apply false
} }
include(":app") include(":app")

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@ -1,5 +1,6 @@
import Flutter import Flutter
import UIKit import UIKit
import UserNotifications
@main @main
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate {
@ -7,7 +8,28 @@ import UIKit
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
// Set notification delegate so notifications show in foreground & background
UNUserNotificationCenter.current().delegate = self
GeneratedPluginRegistrant.register(with: self) GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }
// Called when a notification is delivered while app is in foreground
override func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
) {
completionHandler([.banner, .badge, .sound])
}
// Called when user taps a notification (foreground or background)
override func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
completionHandler()
}
} }

View File

@ -1,3 +1,3 @@
class AppConstant { class AppConstant {
static const String appName = "Apskel Owner"; static const String appName = "Enaklo Owner";
} }

View File

@ -2,9 +2,9 @@ part of 'theme.dart';
class AppColor { class AppColor {
// Primary Colors // Primary Colors
static const Color primary = Color(0xFF36175e); static const Color primary = Color.fromARGB(255, 196, 2, 2); // #d90000
static const Color primaryLight = Color(0xFF5a2d85); static const Color primaryLight = Color(0xFFFF4D4D); // merah terang
static const Color primaryDark = Color(0xFF1e0d35); static const Color primaryDark = Color(0xFF990000); // merah gelap
// Secondary Colors // Secondary Colors
static const Color secondary = Color(0xFF4CAF50); static const Color secondary = Color(0xFF4CAF50);
@ -41,10 +41,9 @@ class AppColor {
// Gradient Colors // Gradient Colors
static const List<Color> primaryGradient = [ static const List<Color> primaryGradient = [
Color(0xFF36175e), Color(0xFFD90000), // primary
Color(0xFF5a2d85), Color(0xFF990000), // dark red
]; ];
static const List<Color> successGradient = [ static const List<Color> successGradient = [
Color(0xFF4CAF50), Color(0xFF4CAF50),
Color(0xFF81C784), Color(0xFF81C784),

View File

@ -1,15 +1,54 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:injectable/injectable.dart'; import 'package:injectable/injectable.dart';
/// Background message handler — must be a top-level function. /// Background message handler — must be a top-level function.
/// Firebase must be initialized here for background isolate.
@pragma('vm:entry-point') @pragma('vm:entry-point')
Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async { Future<void> firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
debugPrint('[FCM] Background message: ${message.messageId}'); debugPrint('[FCM] Background message: ${message.messageId}');
// Show local notification for data-only messages in background
await _showBackgroundNotification(message);
}
/// Standalone local notifications plugin for background isolate use.
final _backgroundLocalNotifications = FlutterLocalNotificationsPlugin();
Future<void> _showBackgroundNotification(RemoteMessage message) async {
const androidInit = AndroidInitializationSettings('@drawable/ic_notification');
await _backgroundLocalNotifications.initialize(
const InitializationSettings(android: androidInit),
);
final notification = message.notification;
// Only show manually for data-only messages (FCM auto-shows notification messages)
if (notification != null) return;
final title = message.data['title'] as String?;
final body = message.data['body'] as String?;
if (title == null && body == null) return;
await _backgroundLocalNotifications.show(
message.hashCode,
title,
body,
const NotificationDetails(
android: AndroidNotificationDetails(
'high_importance_channel',
'High Importance Notifications',
importance: Importance.high,
priority: Priority.high,
icon: '@drawable/ic_notification',
),
),
payload: jsonEncode(message.data),
);
} }
@lazySingleton @lazySingleton
@ -23,7 +62,10 @@ class FcmService {
'high_importance_channel', 'high_importance_channel',
'High Importance Notifications', 'High Importance Notifications',
description: 'This channel is used for important notifications.', description: 'This channel is used for important notifications.',
importance: Importance.high, importance: Importance.max, // max agar banner (heads-up) muncul
playSound: true,
enableVibration: true,
enableLights: true,
); );
/// Call this once during app startup (after Firebase.initializeApp). /// Call this once during app startup (after Firebase.initializeApp).
@ -124,11 +166,19 @@ class FcmService {
_androidChannel.id, _androidChannel.id,
_androidChannel.name, _androidChannel.name,
channelDescription: _androidChannel.description, channelDescription: _androidChannel.description,
icon: '@mipmap/launcher_icon', icon: '@drawable/ic_notification',
importance: Importance.high, importance: Importance.max,
priority: Priority.high, priority: Priority.high,
playSound: true,
enableVibration: true,
// Heads-up notification (banner)
fullScreenIntent: false,
),
iOS: const DarwinNotificationDetails(
presentAlert: true,
presentBadge: true,
presentSound: true,
), ),
iOS: const DarwinNotificationDetails(),
), ),
payload: jsonEncode(message.data), payload: jsonEncode(message.data),
); );

View File

@ -62,36 +62,36 @@ class HomeFeature extends StatelessWidget {
), ),
], ],
), ),
Row( // Row(
mainAxisAlignment: MainAxisAlignment.spaceAround, // mainAxisAlignment: MainAxisAlignment.spaceAround,
crossAxisAlignment: CrossAxisAlignment.start, // crossAxisAlignment: CrossAxisAlignment.start,
children: [ // children: [
HomeFeatureTile( // HomeFeatureTile(
title: context.lang.form, // title: context.lang.form,
color: const Color(0xFFE91E63), // color: const Color(0xFFE91E63),
icon: LineIcons.fileAlt, // icon: LineIcons.fileAlt,
onTap: () => context.router.push(DailyTasksFormRoute()), // onTap: () => context.router.push(DailyTasksFormRoute()),
), // ),
HomeFeatureTile( // HomeFeatureTile(
title: context.lang.schedule, // title: context.lang.schedule,
color: const Color(0xFF9C27B0), // color: const Color(0xFF9C27B0),
icon: LineIcons.calendar, // icon: LineIcons.calendar,
onTap: () => context.router.push(ScheduleRoute()), // onTap: () => context.router.push(ScheduleRoute()),
), // ),
HomeFeatureTile( // HomeFeatureTile(
title: context.lang.inventory, // title: context.lang.inventory,
color: const Color(0xFF00BCD4), // color: const Color(0xFF00BCD4),
icon: LineIcons.archive, // icon: LineIcons.archive,
onTap: () => context.router.push(InventoryRoute()), // onTap: () => context.router.push(InventoryRoute()),
), // ),
HomeFeatureTile( // HomeFeatureTile(
title: context.lang.customer, // title: context.lang.customer,
color: const Color(0xFFFF5722), // color: const Color(0xFFFF5722),
icon: LineIcons.userPlus, // icon: LineIcons.userPlus,
onTap: () => context.router.push(CustomerRoute()), // onTap: () => context.router.push(CustomerRoute()),
), // ),
], // ],
), // ),
], ],
), ),
); );

View File

@ -1,5 +1,7 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:line_icons/line_icons.dart'; import 'package:line_icons/line_icons.dart';
import 'package:loader_overlay/loader_overlay.dart'; import 'package:loader_overlay/loader_overlay.dart';
@ -8,6 +10,7 @@ import '../../../application/auth/auth_bloc.dart';
import '../../../application/auth/logout_form/logout_form_bloc.dart'; import '../../../application/auth/logout_form/logout_form_bloc.dart';
import '../../../common/extension/extension.dart'; import '../../../common/extension/extension.dart';
import '../../../common/theme/theme.dart'; import '../../../common/theme/theme.dart';
import '../../../common/utils/fcm_service.dart';
import '../../../injection.dart'; import '../../../injection.dart';
import '../../components/button/button.dart'; import '../../components/button/button.dart';
import '../../components/spacer/spacer.dart'; import '../../components/spacer/spacer.dart';
@ -61,7 +64,7 @@ class ProfilePage extends StatelessWidget implements AutoRouteWrapper {
backgroundColor: AppColor.primary, backgroundColor: AppColor.primary,
elevation: 0, elevation: 0,
pinned: true, pinned: true,
expandedHeight: 264.0, expandedHeight: 270.0,
flexibleSpace: LayoutBuilder( flexibleSpace: LayoutBuilder(
builder: builder:
(BuildContext context, BoxConstraints constraints) { (BuildContext context, BoxConstraints constraints) {
@ -70,7 +73,7 @@ class ProfilePage extends StatelessWidget implements AutoRouteWrapper {
final double collapsedHeight = final double collapsedHeight =
MediaQuery.of(context).padding.top + MediaQuery.of(context).padding.top +
kToolbarHeight; kToolbarHeight;
final double expandedHeight = 264.0; final double expandedHeight = 270.0;
final double shrinkRatio = final double shrinkRatio =
((expandedHeight - top) / ((expandedHeight - top) /
(expandedHeight - collapsedHeight)) (expandedHeight - collapsedHeight))
@ -125,6 +128,9 @@ class ProfilePage extends StatelessWidget implements AutoRouteWrapper {
const SpaceHeight(12), const SpaceHeight(12),
ProfileDangerZone(), ProfileDangerZone(),
const SpaceHeight(30), const SpaceHeight(30),
// Debug only: FCM Token
if (kDebugMode) const _FcmTokenDebugWidget(),
const SpaceHeight(30),
], ],
), ),
), ),
@ -140,3 +146,84 @@ class ProfilePage extends StatelessWidget implements AutoRouteWrapper {
Widget wrappedRoute(BuildContext context) => Widget wrappedRoute(BuildContext context) =>
BlocProvider(create: (_) => getIt<LogoutFormBloc>(), child: this); BlocProvider(create: (_) => getIt<LogoutFormBloc>(), child: this);
} }
class _FcmTokenDebugWidget extends StatefulWidget {
const _FcmTokenDebugWidget();
@override
State<_FcmTokenDebugWidget> createState() => _FcmTokenDebugWidgetState();
}
class _FcmTokenDebugWidgetState extends State<_FcmTokenDebugWidget> {
String? _token;
@override
void initState() {
super.initState();
_loadToken();
}
Future<void> _loadToken() async {
final token = await getIt<FcmService>().getToken();
if (mounted) setState(() => _token = token);
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColor.black.withOpacity(0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColor.black.withOpacity(0.1)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.bug_report, size: 14),
const SizedBox(width: 4),
Text(
'FCM Token (debug only)',
style: AppStyle.sm.copyWith(fontWeight: FontWeight.w700),
),
const Spacer(),
GestureDetector(
onTap: () async {
if (_token == null) return;
await Clipboard.setData(ClipboardData(text: _token!));
debugPrint('[FCM] Token copied: $_token');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('FCM token copied!'),
duration: Duration(seconds: 2),
),
);
}
},
child: Text(
'Copy',
style: AppStyle.sm.copyWith(
color: AppColor.primary,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 6),
Text(
_token ?? 'Loading...',
style: AppStyle.xs.copyWith(color: AppColor.black.withOpacity(0.6)),
),
],
),
),
);
}
}

View File

@ -70,8 +70,8 @@ flutter:
uses-material-design: true uses-material-design: true
assets: assets:
- assets/images/ - assets/images/
- assets/icons/ # - assets/icons/
- assets/json/ # - assets/json/
fonts: fonts:
- family: Quicksand - family: Quicksand