Skip to content

Commit 23a03a3

Browse files
committed
Make NotificationBehavior configurable always
1 parent 78e16c3 commit 23a03a3

File tree

3 files changed

+107
-51
lines changed

3 files changed

+107
-51
lines changed

lib/alarm_manager_screen.dart

Lines changed: 52 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import 'package:flutter/material.dart';
22
import 'package:flutter_alarm_manager_poc/alarm_actions_screen.dart';
3+
import 'package:flutter_alarm_manager_poc/hive/service/settings_service.dart';
34
import 'package:flutter_alarm_manager_poc/services/export_service.dart';
45
import 'package:flutter_alarm_manager_poc/state/alarm_state_manager.dart';
6+
import 'package:flutter_alarm_manager_poc/state/notification_behavior.dart';
57
import 'package:permission_handler/permission_handler.dart';
68

79
class AlarmManagerScreen extends StatefulWidget {
@@ -14,6 +16,13 @@ class AlarmManagerScreen extends StatefulWidget {
1416
class _AlarmManagerScreenState extends State<AlarmManagerScreen> {
1517
final _exportService = ExportService();
1618
final AlarmStateManager alarmStateManager = AlarmStateManager.instance;
19+
late NotificationBehavior _currentBehavior;
20+
21+
@override
22+
void initState() {
23+
super.initState();
24+
_currentBehavior = SettingsService.instance.getNotificationBehavior();
25+
}
1726

1827
Future<bool> _requestNotificationPermission() async {
1928
final status = await Permission.notification.request();
@@ -33,23 +42,18 @@ class _AlarmManagerScreenState extends State<AlarmManagerScreen> {
3342
}
3443

3544
void _showSnackBar(String message) {
36-
// The 'mounted' check here is a best practice, ensuring the widget
37-
// is still in the tree before attempting to show the SnackBar.
3845
if (!mounted) return;
3946
ScaffoldMessenger.of(context).showSnackBar(
4047
SnackBar(content: Text(message)),
4148
);
4249
}
4350

44-
/// Async handler for changing the export path.
4551
Future<void> _changeExportPath() async {
4652
final result = await _exportService.changeExportPath();
4753
_showSnackBar(result.message);
4854
}
4955

50-
/// Async handler for exporting data.
5156
Future<void> _exportData() async {
52-
// You can add loading indicators here if you wish
5357
final result = await _exportService.exportData();
5458
_showSnackBar(result.message);
5559
}
@@ -85,9 +89,7 @@ class _AlarmManagerScreenState extends State<AlarmManagerScreen> {
8589
],
8690
),
8791
body: StreamBuilder<AlarmState>(
88-
// Listen to the state stream.
8992
stream: alarmStateManager.state,
90-
// Provide the initial state to avoid a null snapshot on the first build.
9193
initialData: alarmStateManager.currentState,
9294
builder: (context, snapshot) {
9395
final state = snapshot.data;
@@ -98,32 +100,53 @@ class _AlarmManagerScreenState extends State<AlarmManagerScreen> {
98100
child: Column(
99101
mainAxisAlignment: MainAxisAlignment.center,
100102
children: [
103+
// --- Notification Behavior Dropdown (always visible) ---
104+
DropdownButtonFormField<NotificationBehavior>(
105+
value: _currentBehavior,
106+
decoration: const InputDecoration(
107+
labelText: 'Notification Type',
108+
border: OutlineInputBorder(),
109+
),
110+
items: NotificationBehavior.values
111+
.map((behavior) => DropdownMenuItem(
112+
value: behavior,
113+
child: Text(behavior.name),
114+
))
115+
.toList(),
116+
onChanged: (newValue) async {
117+
if (newValue != null) {
118+
// 1. Persist the new setting
119+
await SettingsService.instance
120+
.setNotificationBehavior(newValue);
121+
// 2. Update the local UI state
122+
setState(() {
123+
_currentBehavior = newValue;
124+
});
125+
// 3. If a cycle is active, dispatch an event to update it
126+
if (alarmStateManager.currentState is AlarmActive) {
127+
alarmStateManager
128+
.dispatch(NotificationBehaviorChanged(newValue));
129+
}
130+
_showSnackBar(
131+
'Notification type set to ${newValue.name}');
132+
}
133+
},
134+
),
135+
const SizedBox(height: 20),
136+
101137
// --- UI for AlarmIdle state ---
102138
if (state is AlarmIdle) ...[
103139
const Text('Alarm cycle is not running.'),
104140
const SizedBox(height: 20),
105141
ElevatedButton(
106142
onPressed: () async {
107-
// The UI's only job is to request permission and
108-
// then send an event to the state machine.
109143
final granted = await _requestNotificationPermission();
110144
if (granted) {
111145
alarmStateManager.dispatch(CycleStarted());
112146
}
113147
},
114148
child: const Text('Start Alarm Cycle'),
115149
),
116-
const SizedBox(height: 20),
117-
ElevatedButton(
118-
style: ElevatedButton.styleFrom(
119-
backgroundColor: Colors.orange),
120-
onPressed: () {
121-
// The debug button simply sends a different event.
122-
// The state machine handles the logic.
123-
alarmStateManager.dispatch(DebugScheduleRequested());
124-
},
125-
child: const Text('Schedule debug alarm in 10 sec'),
126-
),
127150
],
128151

129152
// --- UI for AlarmActive state ---
@@ -139,11 +162,19 @@ class _AlarmManagerScreenState extends State<AlarmManagerScreen> {
139162
style:
140163
ElevatedButton.styleFrom(backgroundColor: Colors.red),
141164
onPressed: () {
142-
// The cancel button sends the appropriate event.
143165
alarmStateManager.dispatch(CycleCancelled());
144166
},
145167
child: const Text('Cancel Alarm Cycle'),
146168
),
169+
const SizedBox(height: 20),
170+
ElevatedButton(
171+
style: ElevatedButton.styleFrom(
172+
backgroundColor: Colors.orange),
173+
onPressed: () {
174+
alarmStateManager.dispatch(DebugScheduleRequested());
175+
},
176+
child: const Text('Schedule debug alarm in 10 sec'),
177+
),
147178
],
148179
],
149180
),

lib/main.dart

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import 'package:flutter_alarm_manager_poc/utils/alarm_method_channel.dart';
88
void main() async {
99
WidgetsFlutterBinding.ensureInitialized();
1010
await DatabaseService.instance.initializeHive();
11-
// Initialize settings before they are needed by the interpreter.
1211
await SettingsService.instance.initializeHive();
1312
AlarmMethodChannel.initialize();
1413

@@ -17,14 +16,12 @@ void main() async {
1716
// performs side-effects, like calling native code. This cleanly separates
1817
// state management from platform-specific actions.
1918
final alarmStateManager = AlarmStateManager.instance;
20-
final settingsService = SettingsService.instance;
2119

2220
alarmStateManager.state.listen((state) async {
2321
switch (state) {
24-
case AlarmActive(scheduledAt: final time):
22+
case AlarmActive(scheduledAt: final time, behavior: final behavior):
2523
// When the state becomes active, get the current notification setting
2624
// and schedule the alarm on the native side.
27-
final behavior = settingsService.getNotificationBehavior();
2825
await AlarmMethodChannel.schedule(time, behavior);
2926
case AlarmIdle():
3027
// When the state becomes idle, cancel any existing native alarm.

lib/state/alarm_state_manager.dart

Lines changed: 54 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,31 @@
11
import 'dart:async';
22
import 'dart:developer';
33

4+
import 'package:flutter/material.dart';
5+
import 'package:flutter_alarm_manager_poc/hive/service/settings_service.dart';
6+
import 'package:flutter_alarm_manager_poc/state/notification_behavior.dart';
7+
48
// --- STATES ---
5-
// Using a sealed class ensures that we can only be in one of the defined states.
69
sealed class AlarmState {}
710

8-
// The app is idle. No alarm is scheduled.
911
final class AlarmIdle extends AlarmState {}
1012

11-
// The cycle is running. An alarm is ALWAYS scheduled for a specific time.
13+
@immutable
1214
final class AlarmActive extends AlarmState {
13-
AlarmActive({required this.scheduledAt});
15+
AlarmActive({required this.scheduledAt, required this.behavior});
1416
final DateTime scheduledAt;
17+
final NotificationBehavior behavior;
18+
19+
@override
20+
bool operator ==(Object other) =>
21+
identical(this, other) ||
22+
other is AlarmActive &&
23+
runtimeType == other.runtimeType &&
24+
scheduledAt == other.scheduledAt &&
25+
behavior == other.behavior;
26+
27+
@override
28+
int get hashCode => scheduledAt.hashCode ^ behavior.hashCode;
1529
}
1630

1731
// --- EVENTS ---
@@ -29,7 +43,12 @@ final class QuestionnaireFinished extends AlarmEvent {
2943

3044
final class DebugScheduleRequested extends AlarmEvent {}
3145

32-
// Helper enum to make the code more readable and type-safe.
46+
// New event to handle behavior changes.
47+
final class NotificationBehaviorChanged extends AlarmEvent {
48+
NotificationBehaviorChanged(this.newBehavior);
49+
final NotificationBehavior newBehavior;
50+
}
51+
3352
enum QuestionnaireResult { answered, declined, snoozed }
3453

3554
// --- THE STATE MACHINE ("BRAIN") ---
@@ -40,28 +59,16 @@ class AlarmStateManager {
4059

4160
// The initial state of the application.
4261
AlarmState _state = AlarmIdle();
43-
44-
// A broadcast stream controller allows multiple parts of the app (e.g., UI, interpreter)
45-
// to listen to state changes.
4662
final _controller = StreamController<AlarmState>.broadcast();
4763

48-
// Public stream for widgets and services to listen to.
4964
Stream<AlarmState> get state => _controller.stream;
50-
51-
// A way to get the current state synchronously if needed.
5265
AlarmState get currentState => _state;
5366

5467
void dispatch(AlarmEvent event) {
5568
log('Dispatching event: ${event.runtimeType} from state: ${_state.runtimeType}');
56-
57-
// The new state is calculated based on the current state and the incoming event.
5869
final newState = _reduce(_state, event);
5970

60-
// If the state has changed, update it and notify all listeners.
61-
if (newState.runtimeType != _state.runtimeType ||
62-
(newState is AlarmActive &&
63-
_state is AlarmActive &&
64-
newState.scheduledAt != (_state as AlarmActive).scheduledAt)) {
71+
if (newState != _state) {
6572
_state = newState;
6673
_controller.add(_state);
6774
}
@@ -70,11 +77,19 @@ class AlarmStateManager {
7077
// The "reducer" function contains all the transition logic.
7178
// It's a pure function: given a state and an event, it returns a new state.
7279
AlarmState _reduce(AlarmState currentState, AlarmEvent event) {
80+
// Helper to get the current notification setting.
81+
NotificationBehavior getCurrentBehavior() {
82+
return SettingsService.instance.getNotificationBehavior();
83+
}
84+
7385
switch (event) {
7486
case CycleStarted():
7587
// Can only start a cycle if we are currently idle.
7688
if (currentState is AlarmIdle) {
77-
return AlarmActive(scheduledAt: _calculateNextWholeIntervalTime());
89+
return AlarmActive(
90+
scheduledAt: _calculateNextWholeIntervalTime(),
91+
behavior: getCurrentBehavior(),
92+
);
7893
}
7994
case CycleCancelled():
8095
// Can only cancel a cycle if one is active.
@@ -86,28 +101,41 @@ class AlarmStateManager {
86101
// The type of alarm depends on the user's answer.
87102
switch (event.result) {
88103
case QuestionnaireResult.answered:
89-
return AlarmActive(scheduledAt: _calculateNextWholeIntervalTime());
90104
case QuestionnaireResult.declined:
91-
return AlarmActive(scheduledAt: _calculateNextWholeIntervalTime());
105+
return AlarmActive(
106+
scheduledAt: _calculateNextWholeIntervalTime(),
107+
behavior: getCurrentBehavior(),
108+
);
92109
case QuestionnaireResult.snoozed:
93-
return AlarmActive(scheduledAt: _calculateSnoozeTime());
110+
return AlarmActive(
111+
scheduledAt: _calculateSnoozeTime(),
112+
behavior: getCurrentBehavior(),
113+
);
94114
}
95115
case DebugScheduleRequested():
96116
// A special event for testing that schedules an alarm 10 seconds from now.
97117
return AlarmActive(
98-
scheduledAt: DateTime.now().add(const Duration(seconds: 10)));
118+
scheduledAt: DateTime.now().add(const Duration(seconds: 10)),
119+
behavior: getCurrentBehavior(),
120+
);
121+
// Handle the new event to update the behavior of an active alarm.
122+
case NotificationBehaviorChanged(newBehavior: final behavior):
123+
if (currentState is AlarmActive) {
124+
// Create a new state with the same time but new behavior.
125+
return AlarmActive(
126+
scheduledAt: currentState.scheduledAt,
127+
behavior: behavior,
128+
);
129+
}
99130
}
100-
// If the event doesn't cause a state change, return the current state.
101131
return currentState;
102132
}
103133

104-
// --- TIME CALCULATION LOGIC (moved from AlarmMethodChannel) ---
105134
DateTime _calculateNextWholeIntervalTime() {
106135
final now = DateTime.now();
107136
DateTime nextAlarmTime;
108137

109138
if (now.hour >= 20) {
110-
// Changed from 23 to 20 as per original logic
111139
nextAlarmTime = DateTime(now.year, now.month, now.day + 1, 8);
112140
} else if (now.hour < 8) {
113141
nextAlarmTime = DateTime(now.year, now.month, now.day, 8);

0 commit comments

Comments
 (0)