Files
tasq/lib/widgets/update_dialog.dart
T
redz1029 3cb980b629 QA hardening: security RLS fixes, Flutter 3.47.5 upgrade, UI/validation fixes
Security — enforce write authorization server-side (was UI/RPC-only):
- it_service_requests RLS: block cross-office read/edit + self-approve (QA-015)
- pass_slips RLS: owner can complete but not self-approve (QA-046)
- swap_requests RLS: scope select/update to participants + admin (QA-047)
- storage: tighten it_service_attachments + task_attachments write/delete (QA-027)
- admin_user_management edge function: allow programmers to manage users (QA-016)

Fixes:
- workforce generator "uncovered shifts" false alarms (QA-043/044)
- network-map VLAN + New-location dialog validation, disabled-until-valid (QA-048)
- de-flake time-of-day-dependent dashboard metrics test (QA-045)

Toolchain:
- upgrade to Flutter 3.47.5 / Dart 3.13.4; font_awesome_flutter 11.0.0,
  flutter_quill 11.6.0, pdfrx 2.6.5; clear resulting deprecations (QA-002)

analyze clean; 139 tests pass; web build succeeds. Report + evidence in docs/qa/.

Note: also carries the in-progress Brick model cleanup already present in the
working tree. QA-001 (AI keys public in the build) is deferred by owner decision.

Co-Authored-By: claude-flow <ruv@ruv.net>
2026-09-26 11:52:16 +08:00

230 lines
7.4 KiB
Dart

import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_quill/flutter_quill.dart' as quill;
import '../models/app_version.dart';
import '../services/app_update_service.dart';
import '../utils/snackbar.dart';
/// A reusable dialog that can render both flexible and forced updates and
/// report download progress. Callers should wrap this with `showDialog` and
/// control ``barrierDismissible`` according to ``info.isForceUpdate``.
class UpdateDialog extends StatefulWidget {
final AppUpdateInfo info;
const UpdateDialog({required this.info, super.key});
@override
State<UpdateDialog> createState() => _UpdateDialogState();
}
class _UpdateDialogState extends State<UpdateDialog> {
double _realProgress = 0.0;
bool _downloading = false;
bool _failed = false;
List<dynamic>? _notesDelta;
String? _notesPlain;
quill.QuillController? _notesController;
final FocusNode _notesFocusNode = FocusNode();
final ScrollController _notesScrollController = ScrollController();
Future<void> _startDownload() async {
setState(() {
_downloading = true;
_failed = false;
});
try {
await AppUpdateService.instance.downloadAndInstallApk(
widget.info.latestVersion!.downloadUrl,
onProgress: (p) => setState(() => _realProgress = p),
);
// once the installer launches the app is likely to be stopped; we
// don't pop the dialog explicitly.
} catch (err) {
if (!mounted) return;
setState(() {
_failed = true;
_downloading = false;
});
showErrorSnackBar(context, 'Download failed: $err');
}
}
@override
Widget build(BuildContext context) {
final notes = widget.info.latestVersion?.releaseNotes ?? '';
// parse release notes into a Quill delta list if possible
if ((_notesDelta == null && _notesPlain == null) && notes.isNotEmpty) {
try {
final parsed = jsonDecode(notes);
if (parsed is List) {
_notesDelta = parsed;
_notesController = quill.QuillController(
document: quill.Document.fromJson(parsed),
selection: const TextSelection.collapsed(offset: 0),
readOnly: true,
);
// Prevent keyboard focus while still allowing text selection + copy.
_notesFocusNode.canRequestFocus = false;
} else {
_notesPlain = notes;
}
} catch (_) {
_notesPlain = notes;
}
}
// WillPopScope is deprecated in newer Flutter versions but PopScope
// has a different API; to avoid breaking changes we continue to use the
// old widget and suppress the warning.
// ignore: deprecated_member_use
return WillPopScope(
onWillPop: () async {
// prevent the user from dismissing when download is in progress or
// when the dialog is forcing an update
return !widget.info.isForceUpdate && !_downloading;
},
child: AlertDialog(
title: const Text('Update Available'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Show current + new versions
if (widget.info.latestVersion != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(
'Version: ${widget.info.currentBuildNumber} → ${widget.info.latestVersion!.versionCode}',
style: Theme.of(context).textTheme.bodyMedium,
),
),
// Render release notes: prefer Quill delta if available
if (_notesController != null)
SizedBox(
height: 250,
child: quill.QuillEditor.basic(
controller: _notesController!,
focusNode: _notesFocusNode,
scrollController: _notesScrollController,
config: const quill.QuillEditorConfig(
// Keep the editor readable/copyable but avoid showing the
// selection toolbar or receiving keyboard focus.
autoFocus: false,
showCursor: false,
enableInteractiveSelection: true,
enableSelectionToolbar: false,
// Prevent the context menu / selection toolbar from showing.
contextMenuBuilder: _noContextMenu,
),
),
),
if (_notesController == null &&
_notesPlain != null &&
_notesPlain!.isNotEmpty)
SizedBox(
height: 250,
child: SingleChildScrollView(
child: SelectableText(_notesPlain!),
),
),
const SizedBox(height: 12),
if (_downloading) ...[
SizedBox(
width: double.infinity,
child: const LinearProgressIndicator(value: null),
),
const SizedBox(height: 8),
TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: _realProgress),
duration: const Duration(milliseconds: 300),
builder: (context, value, child) {
return Text(
'${(value * 100).toStringAsFixed(value * 100 >= 10 ? 0 : 1)}%',
);
},
),
],
if (_failed)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'An error occurred while downloading. Please try again.',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
),
],
),
actions: _buildActions(),
),
);
}
List<Widget> _buildActions() {
final actions = <Widget>[];
if (!widget.info.isForceUpdate && !_downloading) {
actions.add(
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Later'),
),
);
}
actions.add(
FilledButton(
onPressed: _downloading ? null : _startDownload,
child: _downloading
? const CircularProgressIndicator()
: const Text('Update Now'),
),
);
return actions;
}
@override
void initState() {
super.initState();
// Pre-parse release notes so heavy JSON parsing doesn't block UI later
final notes = widget.info.latestVersion?.releaseNotes ?? '';
if (notes.isNotEmpty) {
try {
final parsed = jsonDecode(notes);
if (parsed is List) {
_notesDelta = parsed;
_notesController = quill.QuillController(
document: quill.Document.fromJson(parsed),
selection: const TextSelection.collapsed(offset: 0),
readOnly: true,
);
// Prevent keyboard focus while still allowing text selection + copy.
_notesFocusNode.canRequestFocus = false;
} else {
_notesPlain = notes;
}
} catch (_) {
_notesPlain = notes;
}
}
}
@override
void dispose() {
_notesFocusNode.dispose();
_notesScrollController.dispose();
super.dispose();
}
static Widget _noContextMenu(BuildContext context, Object state) =>
const SizedBox.shrink();
}