You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
HMG_Patient_App/lib/dev_tools/environment_config_sheet.dart

574 lines
21 KiB
Dart

import 'package:flutter/material.dart';
import 'package:hmg_patient_app/dev_tools/environment_config.dart';
/// Public class — can be used directly with showModalBottomSheet.
class EnvironmentConfigSheet extends StatefulWidget {
const EnvironmentConfigSheet({Key? key}) : super(key: key);
@override
State<EnvironmentConfigSheet> createState() => _EnvironmentConfigSheetState();
}
/// Convenience function — opens the sheet and returns true if app restart needed.
Future<bool> showEnvironmentConfigSheet(BuildContext context) async {
final result = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => const EnvironmentConfigSheet(),
);
return result ?? false;
}
class _EnvironmentConfigSheetState extends State<EnvironmentConfigSheet> {
final _envService = EnvironmentConfigService.instance;
AppEnvironmentPreset? _selectedPreset;
bool _isSaving = false;
bool _isCustomMode = false;
// Controllers for custom URL inputs
final _baseUrlController = TextEditingController();
final _rcBaseUrlController = TextEditingController();
final _pharmacyBaseUrlController = TextEditingController();
final _exaCartBaseUrlController = TextEditingController();
String _selectedPayfortMode = 'production';
@override
void initState() {
super.initState();
_loadCurrent();
}
@override
void dispose() {
_baseUrlController.dispose();
_rcBaseUrlController.dispose();
_pharmacyBaseUrlController.dispose();
_exaCartBaseUrlController.dispose();
super.dispose();
}
Future<void> _loadCurrent() async {
final preset = await _envService.getCurrentPreset();
final currentBaseUrl = await _envService.getCurrentBaseUrl();
final currentRcUrl = await _envService.getCurrentRcUrl();
final currentPharmacyUrl = await _envService.getCurrentPharmacyUrl();
final currentExaCartUrl = await _envService.getCurrentExaCartUrl();
final currentPayfortMode = await _envService.getCurrentPayfortMode();
if (!mounted) return;
setState(() {
// If no preset is stored, fall back to production (first preset)
_selectedPreset = preset ?? kEnvironmentPresets.first;
// Load current values into text controllers
_baseUrlController.text = currentBaseUrl;
_rcBaseUrlController.text = currentRcUrl;
_pharmacyBaseUrlController.text = currentPharmacyUrl;
_exaCartBaseUrlController.text = currentExaCartUrl;
_selectedPayfortMode = currentPayfortMode;
// Check if current config is custom (not matching any preset)
_isCustomMode = preset == null;
});
}
void _selectPreset(AppEnvironmentPreset p) {
setState(() {
_selectedPreset = p;
_isCustomMode = false;
// Update text fields with preset values
_baseUrlController.text = p.baseUrl;
_rcBaseUrlController.text = p.rcBaseUrl;
_pharmacyBaseUrlController.text = p.pharmacyBaseUrl;
_exaCartBaseUrlController.text = p.exaCartBaseUrl;
_selectedPayfortMode = p.payfortMode;
});
}
void _enableCustomMode() {
setState(() {
_isCustomMode = true;
_selectedPreset = null;
});
}
Future<void> _save() async {
setState(() => _isSaving = true);
try {
if (_isCustomMode) {
// Save custom URLs directly
await _envService.applyCustomEnvironment(
baseUrl: _baseUrlController.text.trim(),
rcBaseUrl: _rcBaseUrlController.text.trim(),
pharmacyBaseUrl: _pharmacyBaseUrlController.text.trim(),
exaCartBaseUrl: _exaCartBaseUrlController.text.trim(),
payfortMode: _selectedPayfortMode,
);
} else {
// Save preset
if (_selectedPreset == null) {
setState(() => _isSaving = false);
return;
}
await _envService.applyPreset(_selectedPreset!);
}
if (mounted) Navigator.of(context).pop(true);
} catch (e) {
setState(() => _isSaving = false);
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Failed to save: $e')));
}
}
Future<void> _resetToDefault() async {
setState(() => _isSaving = true);
try {
await _envService.resetToDefault();
if (mounted) Navigator.of(context).pop(true);
} catch (e) {
setState(() => _isSaving = false);
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text('Failed to reset: $e')));
}
}
@override
Widget build(BuildContext context) {
final mq = MediaQuery.of(context);
return Container(
height: mq.size.height * 0.85,
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
// Handle bar
Container(
margin: const EdgeInsets.only(top: 12, bottom: 4),
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2)),
),
// Title row
Padding(
padding: const EdgeInsets.fromLTRB(20, 12, 20, 4),
child: Row(
children: [
const Icon(Icons.tune, color: Color(0xFF2B353E)),
const SizedBox(width: 10),
const Expanded(
child: Text('Environment Configuration',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Color(0xFF2B353E))),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(false)),
],
),
),
const Divider(height: 1),
// Scrollable body
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Preset chips ──────────────────────────────────────
const _SectionLabel('Select Environment'),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
...kEnvironmentPresets
.map((preset) => _Chip(
label: preset.label,
selected: !_isCustomMode && _selectedPreset?.label == preset.label,
onTap: () => _selectPreset(preset),
))
.toList(),
_Chip(
label: 'Custom',
selected: _isCustomMode,
onTap: _enableCustomMode,
isCustom: true,
),
],
),
const SizedBox(height: 24),
// ── URL configuration (editable in custom mode) ────────────────────────────
const _SectionLabel('Environment URLs'),
const SizedBox(height: 10),
_UrlTextField(
label: 'Main Base URL',
controller: _baseUrlController,
enabled: _isCustomMode,
),
const SizedBox(height: 12),
_UrlTextField(
label: 'RC Base URL',
controller: _rcBaseUrlController,
enabled: _isCustomMode,
),
const SizedBox(height: 12),
_UrlTextField(
label: 'Pharmacy Base URL',
controller: _pharmacyBaseUrlController,
enabled: _isCustomMode,
),
const SizedBox(height: 12),
_UrlTextField(
label: 'ExaCart (Offers) URL',
controller: _exaCartBaseUrlController,
enabled: _isCustomMode,
),
const SizedBox(height: 12),
// PayFort Mode selector
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('PayFort Mode',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.grey)),
const SizedBox(height: 6),
Row(
children: [
Expanded(
child: _RadioOption(
label: 'Production',
value: 'production',
groupValue: _selectedPayfortMode,
enabled: _isCustomMode,
onChanged: (val) => setState(() => _selectedPayfortMode = val!),
),
),
const SizedBox(width: 12),
Expanded(
child: _RadioOption(
label: 'Sandbox',
value: 'sandbox',
groupValue: _selectedPayfortMode,
enabled: _isCustomMode,
onChanged: (val) => setState(() => _selectedPayfortMode = val!),
),
),
],
),
],
),
const SizedBox(height: 24),
// ── Reset to Default ──────────────────────────────────
InkWell(
onTap: _isSaving ? null : _resetToDefault,
borderRadius: BorderRadius.circular(10),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 14),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE74C3C)),
borderRadius: BorderRadius.circular(10),
color: const Color(0xFFFFF5F5)),
child: Row(
children: const [
Icon(Icons.restore,
color: Color(0xFFE74C3C), size: 20),
SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Reset to Default',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: Color(0xFFE74C3C))),
SizedBox(height: 2),
Text(
'Clears saved config and uses the URLs defined in code (Production)',
style: TextStyle(
fontSize: 11,
color: Color(0xFFE74C3C))),
],
),
),
],
),
),
),
const SizedBox(height: 20),
// ── Warning ───────────────────────────────────────────
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFFFF3CD),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFFFD700))),
child: const Row(children: [
Icon(Icons.warning_amber_rounded,
color: Color(0xFFE6A817), size: 18),
SizedBox(width: 8),
Expanded(
child: Text(
'After saving, the app will restart to apply the new environment.',
style: TextStyle(
fontSize: 12, color: Color(0xFF856404)),
),
),
]),
),
const SizedBox(height: 80),
],
),
),
),
// ── Save button ───────────────────────────────────────────────
Container(
padding:
EdgeInsets.fromLTRB(20, 12, 20, 12 + mq.padding.bottom),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withAlpha(15),
blurRadius: 8,
offset: const Offset(0, -2))
],
),
child: SizedBox(
width: double.infinity,
height: 50,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF2B353E),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
),
onPressed: _isSaving ? null : _save,
child: _isSaving
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
color: Colors.white, strokeWidth: 2))
: const Text('Save & Restart App',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Colors.white)),
),
),
),
],
),
);
}
}
// ─── Small private helpers ────────────────────────────────────────────────────
class _SectionLabel extends StatelessWidget {
final String text;
const _SectionLabel(this.text);
@override
Widget build(BuildContext context) => Text(text,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: Color(0xFF2B353E)));
}
class _Chip extends StatelessWidget {
final String label;
final bool selected;
final VoidCallback onTap;
final bool isCustom;
const _Chip(
{required this.label, required this.selected, required this.onTap, this.isCustom = false});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
decoration: BoxDecoration(
color: selected
? (isCustom ? const Color(0xFF6C5CE7) : const Color(0xFF2B353E))
: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: selected
? (isCustom ? const Color(0xFF6C5CE7) : const Color(0xFF2B353E))
: Colors.grey.shade300,
width: selected ? 2 : 1),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isCustom && selected) ...[
const Icon(Icons.edit, size: 14, color: Colors.white),
const SizedBox(width: 4),
],
Text(label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: selected ? Colors.white : Colors.black87)),
],
),
),
);
}
}
class _UrlTextField extends StatelessWidget {
final String label;
final TextEditingController controller;
final bool enabled;
const _UrlTextField({
required this.label,
required this.controller,
required this.enabled,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.grey)),
const SizedBox(height: 6),
TextField(
controller: controller,
enabled: enabled,
style: TextStyle(
fontSize: 12,
color: enabled ? const Color(0xFF2B353E) : Colors.grey.shade600),
decoration: InputDecoration(
filled: true,
fillColor: enabled ? Colors.white : Colors.grey.shade100,
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFF6C5CE7), width: 2),
),
disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade200),
),
),
),
],
);
}
}
class _RadioOption extends StatelessWidget {
final String label;
final String value;
final String groupValue;
final bool enabled;
final Function(String?) onChanged;
const _RadioOption({
required this.label,
required this.value,
required this.groupValue,
required this.enabled,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final isSelected = value == groupValue;
return GestureDetector(
onTap: enabled ? () => onChanged(value) : null,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: enabled
? (isSelected ? const Color(0xFF6C5CE7).withAlpha(25) : Colors.white)
: Colors.grey.shade100,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: enabled
? (isSelected ? const Color(0xFF6C5CE7) : Colors.grey.shade300)
: Colors.grey.shade200,
width: isSelected ? 2 : 1,
),
),
child: Row(
children: [
Container(
width: 18,
height: 18,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: enabled
? (isSelected ? const Color(0xFF6C5CE7) : Colors.grey.shade400)
: Colors.grey.shade300,
width: 2,
),
color: isSelected && enabled ? const Color(0xFF6C5CE7) : Colors.transparent,
),
child: isSelected && enabled
? const Icon(Icons.circle, size: 10, color: Colors.white)
: null,
),
const SizedBox(width: 8),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: enabled
? (isSelected ? const Color(0xFF6C5CE7) : Colors.black87)
: Colors.grey.shade500,
),
),
],
),
),
);
}
}