feat(settings): refactor collapsible cards, add feedback controls, and auto-collapse on leave

This commit is contained in:
ssbg04 committed 2026-09-14 12:26:08 +08:00
1 parent d2b3a2b5d6
commit 9d30d86386
4 files changed
+387 -126

No files matched your search

+9
View File
@@ -534,6 +534,15 @@ Files backup/restore - mirror mode or zip -->
# STUDENT SCREEN
- edit student screen -> enrollment tab -> add enrollment modal -> add list of all current enrollments can delete, edit (make this realtime fetch from backend) (done) -->
<!-- # SETTINGS SCREEN
- all widgets that is collapse only title and collapse icon, move inside other components (done)
- add in appearance enable/disable sound and vibration, update the title and make it collapse only title and collapse icon (done) -->
# SETTINGS SCREEN
- default the vibration into disable, vibration hide in windows app (done)
- in academic & auto graduation collapse too many information, remove the duplicated information in this section (done)
- auto uncollapse all collapsable component when leave from this screen (done)
---
# DONT DO
@@ -5,8 +5,11 @@ import 'package:vibration/vibration.dart';
/// Centralized service for dynamic hardware vibration and haptic feedback across Android devices.
class HapticService {
/// Whether dynamic vibration and haptic feedback are enabled (default disabled).
static bool isEnabled = false;
static bool get _isMobile =>
!kIsWeb && (Platform.isAndroid || Platform.isIOS);
isEnabled && !kIsWeb && (Platform.isAndroid || Platform.isIOS);
/// Success dynamic pattern: Crisp double vibration pulse
static Future<void> success() async {
@@ -0,0 +1,69 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../core/services/sound_service.dart';
import '../../core/services/haptic_service.dart';
const _kSoundEnabledKey = 'pref_sound_enabled';
const _kVibrationEnabledKey = 'pref_vibration_enabled';
/// State notifier for global UI sound effects preference.
class SoundEnabledNotifier extends Notifier<bool> {
@override
bool build() {
_loadFromPrefs();
return !SoundService.isMuted;
}
Future<void> _loadFromPrefs() async {
final prefs = await SharedPreferences.getInstance();
final enabled = prefs.getBool(_kSoundEnabledKey) ?? true;
SoundService.isMuted = !enabled;
state = enabled;
}
/// Toggle sound effects on or off and persist.
Future<void> setSoundEnabled(bool enabled) async {
SoundService.isMuted = !enabled;
state = enabled;
if (enabled) {
SoundService.playSuccess();
}
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kSoundEnabledKey, enabled);
}
}
final soundEnabledProvider = NotifierProvider<SoundEnabledNotifier, bool>(
SoundEnabledNotifier.new,
);
/// State notifier for global UI vibration and haptic feedback preference.
class VibrationEnabledNotifier extends Notifier<bool> {
@override
bool build() {
_loadFromPrefs();
return HapticService.isEnabled;
}
Future<void> _loadFromPrefs() async {
final prefs = await SharedPreferences.getInstance();
final enabled = prefs.getBool(_kVibrationEnabledKey) ?? false;
HapticService.isEnabled = enabled;
state = enabled;
}
/// Toggle vibration on or off and persist.
Future<void> setVibrationEnabled(bool enabled) async {
HapticService.isEnabled = enabled;
state = enabled;
if (enabled) {
HapticService.medium();
}
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_kVibrationEnabledKey, enabled);
}
}
final vibrationEnabledProvider = NotifierProvider<VibrationEnabledNotifier, bool>(
VibrationEnabledNotifier.new,
);
@@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'dart:ui';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -21,6 +23,7 @@ import 'teacher_management_screen.dart';
import '../../../domain/entities/setup_models.dart';
import '../../providers/system_settings_provider.dart';
import '../../providers/theme_provider.dart';
import '../../providers/preferences_provider.dart';
import 'widgets/change_password_modal.dart';
import '../../../core/services/haptic_service.dart';
class TitleCaseTextInputFormatter extends TextInputFormatter {
@@ -91,6 +94,14 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
bool _isProfileExpanded = false;
bool _isAcademicYearExpanded = false;
bool _isAutoEnrollExpanded = false;
bool _isAppearanceExpanded = false;
void _collapseAllSections() {
_isProfileExpanded = false;
_isAcademicYearExpanded = false;
_isAutoEnrollExpanded = false;
_isAppearanceExpanded = false;
}
void _refreshAllSettingsData() {
ref.invalidate(academicYearsListProvider);
@@ -111,9 +122,17 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
next,
) {
if (!mounted) return;
if (previous == 'Settings' && next != 'Settings') {
setState(() {
_collapseAllSections();
});
}
if (next == 'Settings') {
_refreshAllSettingsData();
if (previous != 'Settings') {
setState(() {
_collapseAllSections();
});
_firstNameCtrl.clear();
_middleNameCtrl.clear();
_lastNameCtrl.clear();
@@ -453,34 +472,61 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
// ── Profile Card (Collapsible) ──────────────────────────
_buildCollapsibleCard(
leading: const CircleAvatar(
radius: 24,
backgroundColor: AppColors.primaryGreen,
child: Icon(
Icons.person,
size: 24,
color: Colors.white,
),
),
title: 'Profile Details',
subtitle: 'Role: ${user.role.toUpperCase().replaceAll('_', ' ')}',
isExpanded: _isProfileExpanded,
onToggle: () => setState(() => _isProfileExpanded = !_isProfileExpanded),
headerActions: [
if (_isProfileLoading)
const Padding(
padding: EdgeInsets.only(right: 8),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const CircleAvatar(
radius: 24,
backgroundColor: AppColors.primaryGreen,
child: Icon(
Icons.person,
size: 24,
color: Colors.white,
),
),
const SizedBox(width: AppSizes.p16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${user.firstName} ${user.lastName}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
Text(
'Role: ${user.role.toUpperCase().replaceAll('_', ' ')}',
style: TextStyle(
fontSize: 13,
color: Theme.of(context).brightness == Brightness.dark
? AppColors.darkTextSecondary
: Colors.grey,
),
),
],
),
),
if (_isProfileLoading)
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
),
],
child: Form(
key: _profileFormKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
const SizedBox(height: AppSizes.p20),
Form(
key: _profileFormKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CustomTextField(
hintText: 'First Name',
@@ -632,12 +678,14 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
child: const Text('Save Profile'),
),
],
),
],
],
),
),
),
),
],
],
),
),
],
),
),
const SizedBox(height: AppSizes.p24),
@@ -820,24 +868,41 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
Consumer(
builder: (context, ref, _) {
final academicYearsAsync = ref.watch(academicYearsListProvider);
final isDark = Theme.of(context).brightness == Brightness.dark;
return _buildCollapsibleCard(
leading: const Icon(
Icons.school_rounded,
color: AppColors.primaryGreen,
size: 26,
),
title: 'Academic Year & Auto-Graduation',
subtitle: 'Configure schedule dates for automatic sequential graduation of Grade 10 & 12 students',
isExpanded: _isAcademicYearExpanded,
onToggle: () => setState(() => _isAcademicYearExpanded = !_isAcademicYearExpanded),
headerActions: [
IconButton(
icon: const Icon(Icons.refresh, size: 20),
tooltip: 'Refresh Academic Year',
onPressed: () => ref.invalidate(academicYearsListProvider),
),
],
child: academicYearsAsync.when(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(
Icons.school_rounded,
color: AppColors.primaryGreen,
size: 24,
),
const SizedBox(width: AppSizes.p12),
Expanded(
child: Text(
'Manage active academic year and auto-graduation schedule',
style: TextStyle(
fontSize: 13,
color: isDark ? AppColors.darkTextSecondary : Colors.grey.shade600,
),
),
),
IconButton(
icon: const Icon(Icons.refresh, size: 20),
tooltip: 'Refresh Academic Year',
onPressed: () => ref.invalidate(academicYearsListProvider),
),
],
),
const SizedBox(height: AppSizes.p16),
academicYearsAsync.when(
data: (years) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final activeYear = years.cast<AcademicYearModel?>().firstWhere(
@@ -914,32 +979,31 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
),
] else ...[
Wrap(
spacing: 16,
spacing: 20,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: AppColors.primaryGreen.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'Active AY: ${activeYear.yearRange}',
style: const TextStyle(
fontWeight: FontWeight.w600,
color: AppColors.primaryGreen,
fontSize: 13,
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.calendar_today_outlined, size: 15, color: AppColors.primaryGreen),
const SizedBox(width: 6),
Text(
'Start: ${activeYear.startDate ?? "Not set"}',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
),
),
],
),
Text(
'Start Date: ${activeYear.startDate ?? "Not set"}',
style: const TextStyle(fontSize: 13),
),
Text(
'End Date: ${activeYear.endDate ?? "Not set"}',
style: const TextStyle(fontSize: 13),
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.event_available_outlined, size: 15, color: AppColors.primaryGreen),
const SizedBox(width: 6),
Text(
'End: ${activeYear.endDate ?? "Not set"}',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
),
],
),
],
),
@@ -963,7 +1027,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
const SizedBox(width: 8),
Expanded(
child: Text(
'Note: If Start Date or End Date is not set, auto-graduation will not execute automatically. When the end date arrives, Grade 10 students are graduated first, then Grade 12 students.',
'Auto-graduation runs sequentially (Grade 10 then Grade 12) once end date is reached. Requires both dates set.',
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.blue.shade300 : Colors.blue,
@@ -1109,7 +1173,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
loading: () => const Center(child: CircularProgressIndicator()),
error: (err, _) => Text('Error loading academic years: $err', style: const TextStyle(color: Colors.red)),
),
);
],
),
);
},
),
const SizedBox(height: AppSizes.p24),
@@ -1126,32 +1192,58 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
final isDark = Theme.of(context).brightness == Brightness.dark;
return _buildCollapsibleCard(
leading: const Icon(
Icons.auto_awesome_outlined,
color: AppColors.primaryGreen,
size: 26,
),
title: 'Student Enrollment Auto-Update',
subtitle: 'Automatically extract Academic Year, Grade Level, and Section from SF10/SF9 documents',
isExpanded: _isAutoEnrollExpanded,
onToggle: () => setState(() => _isAutoEnrollExpanded = !_isAutoEnrollExpanded),
headerActions: [
Switch(
value: isAutoEnrollEnabled,
activeThumbColor: AppColors.primaryGreen,
onChanged: (val) {
ref
.read(systemSettingsProvider.notifier)
.updateSetting(
'auto_update_enrollment_from_sf',
val ? 'true' : 'false',
);
},
),
],
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
Icons.auto_awesome_outlined,
color: AppColors.primaryGreen,
size: 24,
),
const SizedBox(width: AppSizes.p12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Auto-Update Enrollment',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 2),
Text(
'Automatically extract Academic Year, Grade Level, and Section from SF10/SF9 documents',
style: TextStyle(
fontSize: 12,
color: isDark ? AppColors.darkTextSecondary : Colors.grey.shade600,
),
),
],
),
),
Switch(
value: isAutoEnrollEnabled,
activeThumbColor: AppColors.primaryGreen,
onChanged: (val) {
ref
.read(systemSettingsProvider.notifier)
.updateSetting(
'auto_update_enrollment_from_sf',
val ? 'true' : 'false',
);
},
),
],
),
const SizedBox(height: AppSizes.p16),
if (!isAutoEnrollEnabled)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
@@ -1239,31 +1331,35 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
const SizedBox(height: AppSizes.p24),
],
// ── Appearance / Dark Mode Card ────────────────────────
_buildCard(
// ── Appearance & Feedback Card (Collapsible) ───────────
_buildCollapsibleCard(
title: 'Appearance & Feedback',
isExpanded: _isAppearanceExpanded,
onToggle: () => setState(() => _isAppearanceExpanded = !_isAppearanceExpanded),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(
const Icon(
Icons.palette_outlined,
color: AppColors.primaryGreen,
size: 22,
),
SizedBox(width: AppSizes.p8),
const SizedBox(width: AppSizes.p12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Appearance',
const Text(
'Theme Mode',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
'Choose your preferred theme mode.',
style: TextStyle(
@@ -1279,7 +1375,7 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
),
],
),
const Divider(height: 28),
const SizedBox(height: AppSizes.p16),
Consumer(
builder: (context, ref, child) {
final themeNotifier =
@@ -1318,6 +1414,110 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
);
},
),
const Divider(height: 32),
// Sound Effects Toggle
Consumer(
builder: (context, ref, _) {
final isSoundEnabled = ref.watch(soundEnabledProvider);
final isDark = Theme.of(context).brightness == Brightness.dark;
return Row(
children: [
const Icon(
Icons.volume_up_outlined,
color: AppColors.primaryGreen,
size: 22,
),
const SizedBox(width: AppSizes.p12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Sound Effects',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
'Play audio feedback on user actions and alerts',
style: TextStyle(
fontSize: 13,
color: isDark
? AppColors.darkTextSecondary
: Colors.grey.shade600,
),
),
],
),
),
Switch(
value: isSoundEnabled,
activeThumbColor: AppColors.primaryGreen,
onChanged: (val) {
ref
.read(soundEnabledProvider.notifier)
.setSoundEnabled(val);
},
),
],
);
},
),
if (kIsWeb || !Platform.isWindows) ...[
const SizedBox(height: AppSizes.p16),
// Vibration & Haptics Toggle
Consumer(
builder: (context, ref, _) {
final isVibrationEnabled = ref.watch(vibrationEnabledProvider);
final isDark = Theme.of(context).brightness == Brightness.dark;
return Row(
children: [
const Icon(
Icons.vibration_rounded,
color: AppColors.primaryGreen,
size: 22,
),
const SizedBox(width: AppSizes.p12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Vibration & Haptics',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 2),
Text(
'Enable haptic feedback on touch interactions (mobile)',
style: TextStyle(
fontSize: 13,
color: isDark
? AppColors.darkTextSecondary
: Colors.grey.shade600,
),
),
],
),
),
Switch(
value: isVibrationEnabled,
activeThumbColor: AppColors.primaryGreen,
onChanged: (val) {
ref
.read(vibrationEnabledProvider.notifier)
.setVibrationEnabled(val);
},
),
],
);
},
),
],
],
),
),
@@ -1504,12 +1704,9 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
}
Widget _buildCollapsibleCard({
required Widget leading,
required String title,
required String subtitle,
required bool isExpanded,
required VoidCallback onToggle,
List<Widget>? headerActions,
required Widget child,
}) {
final theme = Theme.of(context);
@@ -1548,32 +1745,15 @@ class _SettingsScreenState extends ConsumerState<SettingsScreen> {
padding: const EdgeInsets.all(AppSizes.p24),
child: Row(
children: [
leading,
const SizedBox(width: AppSizes.p16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(
fontSize: 13,
color: isDark ? AppColors.darkTextSecondary : Colors.grey,
),
),
],
child: Text(
title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
...?headerActions,
const SizedBox(width: 8),
AnimatedRotation(
turns: isExpanded ? 0.5 : 0.0,
duration: const Duration(milliseconds: 250),