import 'dart:convert'; import 'package:anyhow/base.dart'; import 'package:bloc/bloc.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:ot_viewer_app/owntracks_api.dart'; import 'package:shared_preferences/shared_preferences.dart'; class SettingsState { SettingsState({ this.url = '', this.username = '', this.password = '', this.usersToDevices = const {}, this.activeDevices = const {}, }); final String url; final String username; final String password; final Map> usersToDevices; final Set<(String, String)> activeDevices; // Copy constructor SettingsState.copy(SettingsState source) : url = source.url, username = source.username, password = source.password, usersToDevices = Map.from(source.usersToDevices) .map((key, value) => MapEntry(key, List.from(value))), activeDevices = Set.from(source.activeDevices); @override bool operator ==(Object other) => identical(this, other) || other is SettingsState && runtimeType == other.runtimeType && url == other.url && username == other.username && password == other.password && usersToDevices == other.usersToDevices && activeDevices == other.activeDevices; @override int get hashCode => url.hashCode ^ username.hashCode ^ password.hashCode ^ usersToDevices.hashCode ^ activeDevices.hashCode; @override String toString() { return 'SettingsState{url: $url, username: $username, password: $password, usersToDevices: $usersToDevices, activeDevices: $activeDevices}'; } } /* Future loadFromSharedPrefs() async { final sp = await SharedPreferences.getInstance(); final url = sp.getString('url') ?? ''; final username = sp.getString('username') ?? ''; final password = sp.getString('password') ?? ''; // Decode the JSON string and then manually convert to the expected type final usersToDevicesJson = jsonDecode(sp.getString('usersToDevices') ?? '{}') as Map; final Map> usersToDevices = usersToDevicesJson.map((key, value) { // Ensure the value is cast to a List final List list = List.from(value); return MapEntry(key, list); }); // Decode the JSON string for activeDevices. Adjust this part as needed based on your actual data structure final activeDevicesJson = jsonDecode(sp.getString('activeDevices') ?? '[]') as List; final List<(String, String)> activeDevices = List.from(activeDevicesJson); emit(SettingsState( url: url, username: username, password: password, usersToDevices: usersToDevices, activeDevices: activeDevices, )); } */ class SettingsCubit extends HydratedCubit { SettingsCubit() : super(SettingsState()); @override void onChange(Change change) { // TODO: implement onChange super.onChange(change); print(change); } fetchDevices() async { if (state.url.isEmpty) { throw Exception("No URL provided, cannot fetch devices list!"); } final api = OwntracksApi( baseUrl: state.url, username: state.username, pass: state.password, ); switch (await api.getDevices()) { case Ok(:final ok): final currentState = state; // Emit new state emit(SettingsState( url: currentState.url, username: currentState.username, password: currentState.password, usersToDevices: ok, activeDevices: currentState.activeDevices, )); case Err(:final err): throw Exception("Fetching devices list failed: $err"); } } @override SettingsState? fromJson(Map json) { // print("fromjson $json"); final usersToDevicesMap = (json['usersToDevices'] as Map?)?.map((key, value) { final List list = List.from(value as List); return MapEntry(key, list); }) ?? {}; return SettingsState( url: (json['url'] ?? '') as String, username: (json['username'] ?? '') as String, password: (json['password'] ?? '') as String, usersToDevices: usersToDevicesMap, activeDevices: Set<(String, String)>.from( List>.from(json['activeDevices']).map((e) { if (e.length != 2) { return null; } else { return (e[0], e[1]); } }).where((element) => element != null))); } @override Map? toJson(SettingsState state) { // print("tojson $state"); return { 'url': state.url, 'username': state.username, 'password': state.password, 'usersToDevices': state.usersToDevices, 'activeDevices': state.activeDevices.map((e) => [e.$1, e.$2]).toList(), }; } Future updateSettings({ String? url, String? username, String? password, Map>? usersToDevices, Set<(String, String)>? activeDevices, }) async { final currentState = state; // Emit new state emit(SettingsState( url: url ?? currentState.url, username: username ?? currentState.username, password: password ?? currentState.password, usersToDevices: usersToDevices ?? currentState.usersToDevices, activeDevices: activeDevices ?? currentState.activeDevices, )); } } class SettingsPage extends StatefulWidget { const SettingsPage({super.key}); @override createState() => _SettingsPageState(); } class _SettingsPageState extends State { final TextEditingController _urlController = TextEditingController(); final TextEditingController _usernameController = TextEditingController(); final TextEditingController _passwordController = TextEditingController(); @override void dispose() { _urlController.dispose(); _usernameController.dispose(); _passwordController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.only(left: 8, right: 8), child: BlocConsumer( listener: (context, state) { _urlController.text = state.url; _usernameController.text = state.username; _passwordController.text = state.password; }, builder: (context, state) { _urlController.text = state.url; _usernameController.text = state.username; _passwordController.text = state.password; return SingleChildScrollView( child: Column( children: [ Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( border: Border.all( color: Colors.redAccent.withOpacity(0.8), width: 2), borderRadius: BorderRadius.circular(10)), child: Column( mainAxisSize: MainAxisSize.min, // To make the column wrap its content children: [ TextField( controller: _urlController, decoration: const InputDecoration( labelText: 'URL (e.g. https://your-owntracks.host.com)', border: OutlineInputBorder(), // Adds a border to the TextField prefixIcon: Icon( Icons.account_tree), // Adds an icon to the left ), ), const SizedBox(height: 10), // Adds space between the two TextFields TextField( controller: _usernameController, decoration: const InputDecoration( labelText: 'Username', border: OutlineInputBorder(), // Adds a border to the TextField prefixIcon: Icon(Icons.person), // Adds an icon to the left ), ), const SizedBox(height: 10), // Adds space between the two TextFields TextField( controller: _passwordController, obscureText: true, // Hides the text input (for passwords) decoration: const InputDecoration( labelText: 'Password', border: OutlineInputBorder(), // Adds a border to the TextField prefixIcon: Icon(Icons.lock), // Adds an icon to the left ), ), const SizedBox( height: 12, ), Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.warning, color: Colors.red, size: 16), // Small red exclamation mark icon const SizedBox(width: 4), // Space between icon and text const Text( 'Password saved locally', style: TextStyle( color: Colors.red, fontSize: 12, // Small font size for the warning text ), ), const Spacer(), ElevatedButton.icon( onPressed: () { print('saving settings...'); context.read().updateSettings( url: _urlController.text, username: _usernameController.text, password: _passwordController.text, ); context.read().fetchDevices(); }, icon: Icon(Icons.save), label: Text('Save')) ], ), ]), ), const SizedBox(height: 16), Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( border: Border.all( color: Colors.deepPurple.withOpacity(0.8), width: 2), borderRadius: BorderRadius.circular(10)), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: List.from([ Center( child: ElevatedButton.icon( onPressed: () => context.read().fetchDevices(), icon: const Icon(Icons.refresh), label: const Text('Refresh Devices'), ), ), const SizedBox(height: 16), ]) + List.from( state.usersToDevices.entries.map((entry) { return Column( children: [ Row( children: [ const Icon(Icons.person), const SizedBox(width: 4), Text( entry.key, style: const TextStyle( fontWeight: FontWeight.bold, fontSize: 24, ), ), ], ), const Divider(), Container( padding: const EdgeInsets.only(left: 8), child: Column(children: List.from(entry.value.map((device) { return Row( children: [ Checkbox( value: state.activeDevices .contains((entry.key, device)), onChanged: (newVal) { var cubit = context.read(); Set<(String, String)> newSet = Set.from(state.activeDevices); if (newVal ?? false) { newSet.add((entry.key, device)); } else { newSet .remove((entry.key, device)); } cubit.updateSettings( activeDevices: newSet); }), const SizedBox(width: 8), Text("${entry.key}:$device"), ], ); }))), ), const SizedBox(height: 32), ], ); }))), ) ], ), ); }, ), ); } }