@@ -7,6 +7,7 @@ import 'package:admincraft/models/minecraft_edition.dart';
77import 'package:admincraft/models/server_profile.dart' ;
88import 'package:admincraft/models/world_state.dart' ;
99import 'package:admincraft/services/console_parser.dart' ;
10+ import 'package:admincraft/services/console_output_formatter.dart' ;
1011import 'package:admincraft/services/android_widget_service.dart' ;
1112import 'package:admincraft/services/persistence_service.dart' ;
1213import 'package:flutter/material.dart' ;
@@ -16,6 +17,8 @@ class Model with ChangeNotifier {
1617 String _output = '' ;
1718 final Set <String > _seenConsoleEventIds = {};
1819 bool _consoleHistoryLoading = false ;
20+ int _gameruleRefreshDepth = 0 ;
21+ bool _gameruleRefreshDirty = false ;
1922 List <CommandAuditEntry > _commandAudit = [];
2023 final Set <String > _bridgeCapabilities = {};
2124 int ? _bridgeProtocol;
@@ -130,13 +133,15 @@ class Model with ChangeNotifier {
130133 int ? playersOnline,
131134 int ? playerLimit,
132135 Iterable <String >? onlinePlayers,
136+ String ? difficulty,
133137 }) {
134138 _serverRuntimeState = state;
135139 _lastServerStateAt = observedAt ?? DateTime .now ();
136140 _world = _world.copyWith (
137141 daytime: daytime,
138142 playersOnline: playersOnline,
139143 playerLimit: playerLimit,
144+ lastDifficulty: difficulty,
140145 );
141146 if (onlinePlayers != null ) {
142147 _onlinePlayers
@@ -166,9 +171,40 @@ class Model with ChangeNotifier {
166171 void completeConsoleHistoryLoad () {
167172 if (! _consoleHistoryLoading) return ;
168173 _consoleHistoryLoading = false ;
174+ _gameruleRefreshDirty = false ;
169175 notifyListeners ();
170176 }
171177
178+ /// Keeps automatic gamerule discovery out of the user-facing console and
179+ /// coalesces its many state updates into one rebuild at the end.
180+ void beginGameruleRefresh () {
181+ _gameruleRefreshDepth++ ;
182+ if (_gameruleRefreshDepth != 1 ) return ;
183+
184+ final lines = _output.split ('\n ' );
185+ final cleaned = lines
186+ .where (
187+ (line) =>
188+ ! ConsoleParser .isGameruleReply (line, edition: minecraftEdition),
189+ )
190+ .join ('\n ' );
191+ if (cleaned == _output) return ;
192+ _output = cleaned;
193+ _gameruleRefreshDirty = true ;
194+ unawaited (
195+ _persistenceService.saveConsoleOutput (_selectedServerId, _output),
196+ );
197+ }
198+
199+ void completeGameruleRefresh () {
200+ if (_gameruleRefreshDepth == 0 ) return ;
201+ _gameruleRefreshDepth-- ;
202+ if (_gameruleRefreshDepth == 0 && _gameruleRefreshDirty) {
203+ _gameruleRefreshDirty = false ;
204+ if (! _consoleHistoryLoading) notifyListeners ();
205+ }
206+ }
207+
172208 List <ServerProfile > get servers => List .unmodifiable (_servers);
173209 DateTime ? get serversUpdatedAt => _persistenceService.serversUpdatedAt;
174210 int get serverRevision => _serverRevision;
@@ -207,6 +243,7 @@ class Model with ChangeNotifier {
207243 String get consoleTimestampMode => _persistenceService.consoleTimestampMode;
208244 String get consoleFilterPattern => _persistenceService.consoleFilterPattern;
209245 bool get hideCommonConsoleNoise => _persistenceService.hideCommonConsoleNoise;
246+ String get workspaceDestination => _persistenceService.workspaceDestination;
210247
211248 // Provide read-only access to collections
212249 Set <String > get userCommands =>
@@ -271,14 +308,25 @@ class Model with ChangeNotifier {
271308 return created;
272309 }
273310
274- /// Removes a server. The last one is kept: with none left there would be
275- /// nothing for the connection getters to read.
311+ /// Removes a server. The model keeps an internal blank profile when the last
312+ /// real server is removed so connection getters remain safe while the UI
313+ /// returns to onboarding.
276314 Future <void > deleteServer (String id) async {
277- if (_servers.length <= 1 ) return ;
278- _servers = _servers.where ((server) => server.id != id).toList ();
315+ if (! _servers.any ((server) => server.id == id)) return ;
279316 await _persistenceService.forgetServerSecrets (id);
280317 await _persistenceService.forgetConsoleOutput (id);
281- if (_selectedServerId == id) {
318+
319+ if (_servers.length == 1 ) {
320+ final blank = ServerProfile .empty (_newId ());
321+ _servers = [blank];
322+ _selectedServerId = blank.id;
323+ _resetSession ();
324+ await _persistenceService.saveSelectedServerId (_selectedServerId);
325+ await _persistenceService.resetOnboarding ();
326+ } else {
327+ _servers = _servers.where ((server) => server.id != id).toList ();
328+ }
329+ if (! _servers.any ((server) => server.id == _selectedServerId)) {
282330 _selectedServerId = _servers.first.id;
283331 _resetSession ();
284332 await _persistenceService.saveSelectedServerId (_selectedServerId);
@@ -453,6 +501,13 @@ class Model with ChangeNotifier {
453501 notifyListeners ();
454502 }
455503
504+ /// Applies a time selected in Controls immediately. The server's later
505+ /// state event remains authoritative and will correct this if necessary.
506+ void recordDaytime (int daytime) {
507+ _world = _world.copyWith (daytime: daytime % 24000 );
508+ notifyListeners ();
509+ }
510+
456511 /// Resets live state when the selected server changes. Each profile keeps
457512 /// its own transcript, so locally echoed commands do not disappear between
458513 /// app launches or server switches.
@@ -560,6 +615,7 @@ class Model with ChangeNotifier {
560615 String command, {
561616 bool visible = true ,
562617 String ? eventId,
618+ bool isUserCommand = false ,
563619 }) {
564620 if (eventId != null && ! _seenConsoleEventIds.add (eventId)) return ;
565621 while (_seenConsoleEventIds.length > 2000 ) {
@@ -573,8 +629,15 @@ class Model with ChangeNotifier {
573629 _world.playerLimit != previousLimit) {
574630 unawaited (AndroidWidgetService .update (selectedServer, _world));
575631 }
576- if (visible) {
577- _output += "$command \n " ;
632+ final showInConsole =
633+ visible &&
634+ ! (_gameruleRefreshDepth > 0 &&
635+ ConsoleParser .isGameruleReply (command, edition: minecraftEdition));
636+ if (showInConsole) {
637+ final stored = isUserCommand
638+ ? ConsoleOutputFormatter .markUserCommand (command)
639+ : command;
640+ _output += "$stored \n " ;
578641 final lines = _output.split ('\n ' );
579642 if (lines.length > _persistenceService.maxOutLines) {
580643 _output = lines
@@ -593,7 +656,15 @@ class Model with ChangeNotifier {
593656 ),
594657 );
595658 }
596- notifyListeners ();
659+ // Protocol-v2 history can contain hundreds of lines. Rebuilding the
660+ // terminal for every one paints it repeatedly at intermediate heights and
661+ // produces the visible top-to-bottom bounce. The completion event emits
662+ // one notification after the bounded snapshot has arrived.
663+ if (_consoleHistoryLoading || _gameruleRefreshDepth > 0 ) {
664+ _gameruleRefreshDirty = true ;
665+ } else {
666+ notifyListeners ();
667+ }
597668 }
598669
599670 Future <void > addCommandToHistory (String command) async {
@@ -656,6 +727,9 @@ class Model with ChangeNotifier {
656727 );
657728 }
658729
730+ Future <void > setWorkspaceDestination (String value) =>
731+ _persistenceService.saveWorkspaceDestination (value);
732+
659733 Future <void > addFavoriteCommand (String command) async {
660734 final normalized = command.trim ();
661735 if (normalized.isEmpty || favoriteCommands.contains (normalized)) return ;
@@ -674,4 +748,19 @@ class Model with ChangeNotifier {
674748 ),
675749 );
676750 }
751+
752+ Future <void > updateFavoriteCommand (String previous, String command) async {
753+ final normalized = command.trim ();
754+ if (normalized.isEmpty) return ;
755+ final favorites = favoriteCommands.toList ();
756+ final index = favorites.indexOf (previous);
757+ if (index < 0 ) return ;
758+ if (favorites.any ((value) => value == normalized && value != previous)) {
759+ return ;
760+ }
761+ favorites[index] = normalized;
762+ await _updatePersistenceService (
763+ () => _persistenceService.saveFavoriteCommands (favorites),
764+ );
765+ }
677766}
0 commit comments