From 60e013fe59320cf8f65763bcb0bd1642eee41923 Mon Sep 17 00:00:00 2001 From: suu <46421931+0suu@users.noreply.github.com> Date: Tue, 3 Feb 2026 20:13:10 -0800 Subject: [PATCH] Add Flutter port skeleton --- flutter_app/.gitignore | 7 + flutter_app/README.md | 18 ++ flutter_app/lib/app_state.dart | 248 ++++++++++++++++++ flutter_app/lib/l10n.dart | 43 +++ flutter_app/lib/main.dart | 119 +++++++++ flutter_app/lib/models.dart | 54 ++++ .../lib/screens/device_detail_screen.dart | 145 ++++++++++ flutter_app/lib/screens/devices_screen.dart | 110 ++++++++ flutter_app/lib/screens/scenes_screen.dart | 92 +++++++ flutter_app/lib/screens/settings_screen.dart | 182 +++++++++++++ flutter_app/lib/services/switchbot_api.dart | 124 +++++++++ flutter_app/pubspec.yaml | 17 ++ 12 files changed, 1159 insertions(+) create mode 100644 flutter_app/.gitignore create mode 100644 flutter_app/README.md create mode 100644 flutter_app/lib/app_state.dart create mode 100644 flutter_app/lib/l10n.dart create mode 100644 flutter_app/lib/main.dart create mode 100644 flutter_app/lib/models.dart create mode 100644 flutter_app/lib/screens/device_detail_screen.dart create mode 100644 flutter_app/lib/screens/devices_screen.dart create mode 100644 flutter_app/lib/screens/scenes_screen.dart create mode 100644 flutter_app/lib/screens/settings_screen.dart create mode 100644 flutter_app/lib/services/switchbot_api.dart create mode 100644 flutter_app/pubspec.yaml diff --git a/flutter_app/.gitignore b/flutter_app/.gitignore new file mode 100644 index 0000000..6a9ff24 --- /dev/null +++ b/flutter_app/.gitignore @@ -0,0 +1,7 @@ +.dart_tool/ +.packages +build/ +.pub/ +.idea/ +.vscode/ +*.iml diff --git a/flutter_app/README.md b/flutter_app/README.md new file mode 100644 index 0000000..99d677e --- /dev/null +++ b/flutter_app/README.md @@ -0,0 +1,18 @@ +# SwitchBot Controller Flutter + +Flutter 版の SwitchBot Controller です。既存の Electron 版と同じ機能を提供することを目的としています。 + +## セットアップ +```bash +cd flutter_app +flutter pub get +flutter run +``` + +## 機能 +- デバイス一覧表示・絞り込み・詳細のステータス表示 +- デバイスコマンドの送信 +- シーン一覧の取得と実行 +- API トークン/シークレットの保存と検証 +- ポーリング間隔、テーマ、言語の設定 +- 最後に開いていたタブの復元 diff --git a/flutter_app/lib/app_state.dart b/flutter_app/lib/app_state.dart new file mode 100644 index 0000000..ac53065 --- /dev/null +++ b/flutter_app/lib/app_state.dart @@ -0,0 +1,248 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import 'models.dart'; +import 'services/switchbot_api.dart'; + +class AppState extends ChangeNotifier { + AppState({required this.api}); + + final SwitchBotApi api; + + String _token = ''; + String _secret = ''; + int _pollingIntervalSeconds = 10; + ThemeMode _themeMode = ThemeMode.system; + AppLanguage _language = AppLanguage.japanese; + int _lastTabIndex = 0; + + Timer? _pollingTimer; + + List _devices = []; + List _scenes = []; + bool _isLoading = false; + String? _errorMessage; + + String get token => _token; + String get secret => _secret; + int get pollingIntervalSeconds => _pollingIntervalSeconds; + ThemeMode get themeMode => _themeMode; + AppLanguage get language => _language; + int get lastTabIndex => _lastTabIndex; + + List get devices => _devices; + List get scenes => _scenes; + bool get isLoading => _isLoading; + String? get errorMessage => _errorMessage; + + Future load() async { + final prefs = await SharedPreferences.getInstance(); + _token = prefs.getString('token') ?? ''; + _secret = prefs.getString('secret') ?? ''; + _pollingIntervalSeconds = prefs.getInt('pollingIntervalSeconds') ?? 10; + _themeMode = ThemeMode.values[prefs.getInt('themeMode') ?? ThemeMode.system.index]; + _language = AppLanguage.values[prefs.getInt('language') ?? AppLanguage.japanese.index]; + _lastTabIndex = prefs.getInt('lastTabIndex') ?? 0; + notifyListeners(); + + _restartPolling(); + } + + Future saveCredentials(String token, String secret) async { + _token = token; + _secret = secret; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString('token', token); + await prefs.setString('secret', secret); + notifyListeners(); + } + + Future clearCredentials() async { + _token = ''; + _secret = ''; + final prefs = await SharedPreferences.getInstance(); + await prefs.remove('token'); + await prefs.remove('secret'); + notifyListeners(); + } + + Future setPollingIntervalSeconds(int value) async { + _pollingIntervalSeconds = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('pollingIntervalSeconds', value); + notifyListeners(); + _restartPolling(); + } + + Future setThemeMode(ThemeMode value) async { + _themeMode = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('themeMode', value.index); + notifyListeners(); + } + + Future setLanguage(AppLanguage value) async { + _language = value; + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('language', value.index); + notifyListeners(); + } + + Future setLastTabIndex(int index) async { + _lastTabIndex = index; + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt('lastTabIndex', index); + notifyListeners(); + } + + Future validateCredentials() async { + if (_token.isEmpty || _secret.isEmpty) { + _errorMessage = '認証情報が未入力です。'; + notifyListeners(); + return false; + } + try { + await api.fetchDevices(token: _token, secret: _secret); + _errorMessage = null; + notifyListeners(); + return true; + } catch (error) { + _errorMessage = error.toString(); + notifyListeners(); + return false; + } + } + + Future refreshDevices({bool showLoading = true}) async { + if (_token.isEmpty || _secret.isEmpty) { + _errorMessage = '認証情報が未入力です。'; + notifyListeners(); + return; + } + if (showLoading) { + _isLoading = true; + notifyListeners(); + } + try { + _devices = await api.fetchDevices(token: _token, secret: _secret); + _errorMessage = null; + } catch (error) { + _errorMessage = error.toString(); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future refreshScenes({bool showLoading = true}) async { + if (_token.isEmpty || _secret.isEmpty) { + _errorMessage = '認証情報が未入力です。'; + notifyListeners(); + return; + } + if (showLoading) { + _isLoading = true; + notifyListeners(); + } + try { + _scenes = await api.fetchScenes(token: _token, secret: _secret); + _errorMessage = null; + } catch (error) { + _errorMessage = error.toString(); + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future fetchDeviceStatus(String deviceId) async { + if (_token.isEmpty || _secret.isEmpty) { + _errorMessage = '認証情報が未入力です。'; + notifyListeners(); + return null; + } + try { + final status = await api.fetchDeviceStatus( + token: _token, + secret: _secret, + deviceId: deviceId, + ); + _errorMessage = null; + notifyListeners(); + return status; + } catch (error) { + _errorMessage = error.toString(); + notifyListeners(); + return null; + } + } + + Future executeScene(String sceneId) async { + if (_token.isEmpty || _secret.isEmpty) { + _errorMessage = '認証情報が未入力です。'; + notifyListeners(); + return false; + } + try { + await api.executeScene(token: _token, secret: _secret, sceneId: sceneId); + _errorMessage = null; + notifyListeners(); + return true; + } catch (error) { + _errorMessage = error.toString(); + notifyListeners(); + return false; + } + } + + Future sendCommand( + String deviceId, { + required String command, + required String commandType, + String? parameter, + }) async { + if (_token.isEmpty || _secret.isEmpty) { + _errorMessage = '認証情報が未入力です。'; + notifyListeners(); + return false; + } + try { + await api.sendCommand( + token: _token, + secret: _secret, + deviceId: deviceId, + command: command, + commandType: commandType, + parameter: parameter, + ); + _errorMessage = null; + notifyListeners(); + return true; + } catch (error) { + _errorMessage = error.toString(); + notifyListeners(); + return false; + } + } + + void _restartPolling() { + _pollingTimer?.cancel(); + if (_pollingIntervalSeconds <= 0) { + return; + } + _pollingTimer = Timer.periodic( + Duration(seconds: _pollingIntervalSeconds), + (_) { + refreshDevices(showLoading: false); + }, + ); + } + + @override + void dispose() { + _pollingTimer?.cancel(); + super.dispose(); + } +} diff --git a/flutter_app/lib/l10n.dart b/flutter_app/lib/l10n.dart new file mode 100644 index 0000000..c8e8a65 --- /dev/null +++ b/flutter_app/lib/l10n.dart @@ -0,0 +1,43 @@ +import 'models.dart'; + +class AppStrings { + AppStrings(this.language); + + final AppLanguage language; + + String get appTitle => _text('SwitchBot Controller', 'SwitchBot Controller'); + String get devices => _text('Devices', 'デバイス'); + String get scenes => _text('Scenes', 'シーン'); + String get settings => _text('Settings', '設定'); + String get tokenLabel => _text('Open Token', 'Open Token'); + String get secretLabel => _text('Secret Key', 'Secret Key'); + String get validateSave => _text('Validate and Save', '検証して保存'); + String get clearCredentials => _text('Clear Stored Credentials', '保存済み認証情報を削除'); + String get pollingInterval => _text('Polling Interval (sec)', 'ポーリング間隔(秒)'); + String get theme => _text('Theme', 'テーマ'); + String get language => _text('Language', '言語'); + String get refresh => _text('Refresh', '更新'); + String get filter => _text('Filter', '絞り込み'); + String get status => _text('Status', 'ステータス'); + String get execute => _text('Execute', '実行'); + String get command => _text('Command', 'コマンド'); + String get commandType => _text('Command Type', 'コマンド種別'); + String get parameter => _text('Parameter', 'パラメータ'); + String get sendCommand => _text('Send Command', 'コマンド送信'); + String get updateStatus => _text('Update Status', 'ステータス更新'); + String get noDevices => _text('No devices available.', 'デバイスがありません。'); + String get noScenes => _text('No scenes available.', 'シーンがありません。'); + String get validateSuccess => _text('Validation success.', '検証に成功しました。'); + String get validateFailure => _text('Validation failed.', '検証に失敗しました。'); + String get selectThemeSystem => _text('Follow System', 'システムに追従'); + String get selectThemeLight => _text('Light', 'ライト'); + String get selectThemeDark => _text('Dark', 'ダーク'); + String get selectLanguageJapanese => _text('Japanese', '日本語'); + String get selectLanguageEnglish => _text('English', 'English'); + String get lastUpdated => _text('Last Updated', '最終更新'); + String get apiError => _text('API error', 'API エラー'); + + String _text(String en, String ja) { + return language == AppLanguage.english ? en : ja; + } +} diff --git a/flutter_app/lib/main.dart b/flutter_app/lib/main.dart new file mode 100644 index 0000000..07eea92 --- /dev/null +++ b/flutter_app/lib/main.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; + +import 'app_state.dart'; +import 'l10n.dart'; +import 'models.dart'; +import 'screens/devices_screen.dart'; +import 'screens/scenes_screen.dart'; +import 'screens/settings_screen.dart'; +import 'services/switchbot_api.dart'; + +void main() { + runApp(const SwitchBotApp()); +} + +class SwitchBotApp extends StatefulWidget { + const SwitchBotApp({super.key}); + + @override + State createState() => _SwitchBotAppState(); +} + +class _SwitchBotAppState extends State { + late final AppState _appState; + + @override + void initState() { + super.initState(); + _appState = AppState(api: SwitchBotApi()); + _appState.load(); + } + + @override + void dispose() { + _appState.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _appState, + builder: (context, _) { + final strings = AppStrings(_appState.language); + return MaterialApp( + title: strings.appTitle, + themeMode: _appState.themeMode, + theme: ThemeData.light(useMaterial3: true), + darkTheme: ThemeData.dark(useMaterial3: true), + home: HomeScreen(appState: _appState, strings: strings), + ); + }, + ); + } +} + +class HomeScreen extends StatefulWidget { + const HomeScreen({ + super.key, + required this.appState, + required this.strings, + }); + + final AppState appState; + final AppStrings strings; + + @override + State createState() => _HomeScreenState(); +} + +class _HomeScreenState extends State { + late int _currentIndex; + + @override + void initState() { + super.initState(); + _currentIndex = widget.appState.lastTabIndex; + } + + void _onTabSelected(int index) { + setState(() { + _currentIndex = index; + }); + widget.appState.setLastTabIndex(index); + } + + @override + Widget build(BuildContext context) { + final screens = [ + DevicesScreen(appState: widget.appState, strings: widget.strings), + ScenesScreen(appState: widget.appState, strings: widget.strings), + SettingsScreen(appState: widget.appState, strings: widget.strings), + ]; + + return Scaffold( + appBar: AppBar( + title: Text(widget.strings.appTitle), + ), + body: screens[_currentIndex], + bottomNavigationBar: NavigationBar( + selectedIndex: _currentIndex, + onDestinationSelected: _onTabSelected, + destinations: [ + NavigationDestination( + icon: const Icon(Icons.devices), + label: widget.strings.devices, + ), + NavigationDestination( + icon: const Icon(Icons.movie_filter), + label: widget.strings.scenes, + ), + NavigationDestination( + icon: const Icon(Icons.settings), + label: widget.strings.settings, + ), + ], + ), + ); + } +} diff --git a/flutter_app/lib/models.dart b/flutter_app/lib/models.dart new file mode 100644 index 0000000..2a91167 --- /dev/null +++ b/flutter_app/lib/models.dart @@ -0,0 +1,54 @@ +class Device { + Device({ + required this.deviceId, + required this.deviceName, + required this.deviceType, + required this.enableCloudService, + required this.hubDeviceId, + }); + + final String deviceId; + final String deviceName; + final String deviceType; + final bool enableCloudService; + final String? hubDeviceId; + + factory Device.fromJson(Map json) { + return Device( + deviceId: json['deviceId'] as String? ?? '', + deviceName: json['deviceName'] as String? ?? '', + deviceType: json['deviceType'] as String? ?? '', + enableCloudService: json['enableCloudService'] as bool? ?? false, + hubDeviceId: json['hubDeviceId'] as String?, + ); + } +} + +class DeviceStatus { + DeviceStatus({required this.rawData}); + + final Map rawData; + + factory DeviceStatus.fromJson(Map json) { + return DeviceStatus(rawData: json); + } +} + +class Scene { + Scene({required this.sceneId, required this.sceneName}); + + final String sceneId; + final String sceneName; + + factory Scene.fromJson(Map json) { + return Scene( + sceneId: json['sceneId'] as String? ?? '', + sceneName: json['sceneName'] as String? ?? '', + ); + } +} + +enum AppLanguage { + japanese, + english, +} diff --git a/flutter_app/lib/screens/device_detail_screen.dart b/flutter_app/lib/screens/device_detail_screen.dart new file mode 100644 index 0000000..db1391b --- /dev/null +++ b/flutter_app/lib/screens/device_detail_screen.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; + +import '../app_state.dart'; +import '../l10n.dart'; +import '../models.dart'; + +class DeviceDetailScreen extends StatefulWidget { + const DeviceDetailScreen({ + super.key, + required this.appState, + required this.strings, + required this.device, + }); + + final AppState appState; + final AppStrings strings; + final Device device; + + @override + State createState() => _DeviceDetailScreenState(); +} + +class _DeviceDetailScreenState extends State { + DeviceStatus? _status; + DateTime? _lastUpdated; + final TextEditingController _commandController = TextEditingController(); + final TextEditingController _commandTypeController = + TextEditingController(text: 'command'); + final TextEditingController _parameterController = TextEditingController(); + + @override + void initState() { + super.initState(); + _refreshStatus(); + } + + @override + void dispose() { + _commandController.dispose(); + _commandTypeController.dispose(); + _parameterController.dispose(); + super.dispose(); + } + + Future _refreshStatus() async { + final status = await widget.appState.fetchDeviceStatus(widget.device.deviceId); + if (!mounted) return; + setState(() { + _status = status; + _lastUpdated = DateTime.now(); + }); + } + + Future _sendCommand() async { + final command = _commandController.text.trim(); + final commandType = _commandTypeController.text.trim(); + if (command.isEmpty || commandType.isEmpty) { + return; + } + await widget.appState.sendCommand( + widget.device.deviceId, + command: command, + commandType: commandType, + parameter: _parameterController.text.trim().isEmpty + ? null + : _parameterController.text.trim(), + ); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('コマンドを送信しました。')), + ); + } + + @override + Widget build(BuildContext context) { + final statusEntries = _status?.rawData.entries.toList() ?? []; + + return Scaffold( + appBar: AppBar( + title: Text(widget.device.deviceName), + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text(widget.device.deviceType, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + Text('ID: ${widget.device.deviceId}'), + if (widget.device.hubDeviceId != null) + Text('Hub: ${widget.device.hubDeviceId}'), + const Divider(height: 32), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(widget.strings.status, style: Theme.of(context).textTheme.titleMedium), + TextButton.icon( + onPressed: _refreshStatus, + icon: const Icon(Icons.refresh), + label: Text(widget.strings.updateStatus), + ), + ], + ), + if (_lastUpdated != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text('${widget.strings.lastUpdated}: ${_lastUpdated!.toLocal()}'), + ), + if (widget.appState.errorMessage != null) + Text( + widget.appState.errorMessage!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + if (statusEntries.isEmpty) + const Text('ステータスがありません。') + else + ...statusEntries.map( + (entry) => ListTile( + title: Text(entry.key), + subtitle: Text(entry.value.toString()), + ), + ), + const Divider(height: 32), + Text(widget.strings.sendCommand, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + TextField( + controller: _commandController, + decoration: InputDecoration(labelText: widget.strings.command), + ), + TextField( + controller: _commandTypeController, + decoration: InputDecoration(labelText: widget.strings.commandType), + ), + TextField( + controller: _parameterController, + decoration: InputDecoration(labelText: widget.strings.parameter), + ), + const SizedBox(height: 12), + ElevatedButton( + onPressed: _sendCommand, + child: Text(widget.strings.sendCommand), + ), + ], + ), + ); + } +} diff --git a/flutter_app/lib/screens/devices_screen.dart b/flutter_app/lib/screens/devices_screen.dart new file mode 100644 index 0000000..255d3e9 --- /dev/null +++ b/flutter_app/lib/screens/devices_screen.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; + +import '../app_state.dart'; +import '../l10n.dart'; +import '../models.dart'; +import 'device_detail_screen.dart'; + +class DevicesScreen extends StatefulWidget { + const DevicesScreen({ + super.key, + required this.appState, + required this.strings, + }); + + final AppState appState; + final AppStrings strings; + + @override + State createState() => _DevicesScreenState(); +} + +class _DevicesScreenState extends State { + String _filterText = ''; + + @override + void initState() { + super.initState(); + widget.appState.refreshDevices(); + } + + @override + Widget build(BuildContext context) { + final devices = widget.appState.devices + .where((device) => device.deviceName.contains(_filterText)) + .toList(); + + return RefreshIndicator( + onRefresh: () => widget.appState.refreshDevices(), + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + TextField( + decoration: InputDecoration( + labelText: widget.strings.filter, + prefixIcon: const Icon(Icons.search), + ), + onChanged: (value) { + setState(() { + _filterText = value; + }); + }, + ), + const SizedBox(height: 12), + if (widget.appState.isLoading) + const LinearProgressIndicator(), + if (widget.appState.errorMessage != null) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + widget.appState.errorMessage!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + if (devices.isEmpty && !widget.appState.isLoading) + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text(widget.strings.noDevices), + ) + else + ...devices.map( + (device) => _DeviceTile( + device: device, + onTap: () async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => DeviceDetailScreen( + appState: widget.appState, + strings: widget.strings, + device: device, + ), + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +class _DeviceTile extends StatelessWidget { + const _DeviceTile({required this.device, required this.onTap}); + + final Device device; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(vertical: 8), + child: ListTile( + title: Text(device.deviceName), + subtitle: Text(device.deviceType), + trailing: const Icon(Icons.chevron_right), + onTap: onTap, + ), + ); + } +} diff --git a/flutter_app/lib/screens/scenes_screen.dart b/flutter_app/lib/screens/scenes_screen.dart new file mode 100644 index 0000000..ea17e99 --- /dev/null +++ b/flutter_app/lib/screens/scenes_screen.dart @@ -0,0 +1,92 @@ +import 'package:flutter/material.dart'; + +import '../app_state.dart'; +import '../l10n.dart'; +import '../models.dart'; + +class ScenesScreen extends StatefulWidget { + const ScenesScreen({ + super.key, + required this.appState, + required this.strings, + }); + + final AppState appState; + final AppStrings strings; + + @override + State createState() => _ScenesScreenState(); +} + +class _ScenesScreenState extends State { + @override + void initState() { + super.initState(); + widget.appState.refreshScenes(); + } + + @override + Widget build(BuildContext context) { + final scenes = widget.appState.scenes; + + return RefreshIndicator( + onRefresh: () => widget.appState.refreshScenes(), + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + if (widget.appState.isLoading) + const LinearProgressIndicator(), + if (widget.appState.errorMessage != null) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + widget.appState.errorMessage!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + if (scenes.isEmpty && !widget.appState.isLoading) + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text(widget.strings.noScenes), + ) + else + ...scenes.map( + (scene) => _SceneTile( + scene: scene, + onExecute: () async { + final success = await widget.appState.executeScene(scene.sceneId); + if (!mounted) return; + final message = success + ? '${scene.sceneName} を実行しました。' + : widget.strings.apiError; + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(message))); + }, + ), + ), + ], + ), + ); + } +} + +class _SceneTile extends StatelessWidget { + const _SceneTile({required this.scene, required this.onExecute}); + + final Scene scene; + final VoidCallback onExecute; + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.symmetric(vertical: 8), + child: ListTile( + title: Text(scene.sceneName), + trailing: TextButton( + onPressed: onExecute, + child: const Text('実行'), + ), + ), + ); + } +} diff --git a/flutter_app/lib/screens/settings_screen.dart b/flutter_app/lib/screens/settings_screen.dart new file mode 100644 index 0000000..4d1ce7d --- /dev/null +++ b/flutter_app/lib/screens/settings_screen.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; + +import '../app_state.dart'; +import '../l10n.dart'; +import '../models.dart'; + +class SettingsScreen extends StatefulWidget { + const SettingsScreen({ + super.key, + required this.appState, + required this.strings, + }); + + final AppState appState; + final AppStrings strings; + + @override + State createState() => _SettingsScreenState(); +} + +class _SettingsScreenState extends State { + late final TextEditingController _tokenController; + late final TextEditingController _secretController; + late final TextEditingController _pollingController; + + @override + void initState() { + super.initState(); + _tokenController = TextEditingController(text: widget.appState.token); + _secretController = TextEditingController(text: widget.appState.secret); + _pollingController = + TextEditingController(text: widget.appState.pollingIntervalSeconds.toString()); + } + + @override + void dispose() { + _tokenController.dispose(); + _secretController.dispose(); + _pollingController.dispose(); + super.dispose(); + } + + Future _validateAndSave() async { + await widget.appState.saveCredentials( + _tokenController.text.trim(), + _secretController.text.trim(), + ); + final success = await widget.appState.validateCredentials(); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + success ? widget.strings.validateSuccess : widget.strings.validateFailure, + ), + ), + ); + } + + Future _applyPollingInterval() async { + final value = int.tryParse(_pollingController.text.trim()) ?? 0; + await widget.appState.setPollingIntervalSeconds(value); + } + + @override + Widget build(BuildContext context) { + return ListView( + padding: const EdgeInsets.all(16), + children: [ + TextField( + controller: _tokenController, + decoration: InputDecoration(labelText: widget.strings.tokenLabel), + ), + const SizedBox(height: 8), + TextField( + controller: _secretController, + decoration: InputDecoration(labelText: widget.strings.secretLabel), + ), + const SizedBox(height: 12), + ElevatedButton( + onPressed: _validateAndSave, + child: Text(widget.strings.validateSave), + ), + TextButton( + onPressed: widget.appState.clearCredentials, + child: Text(widget.strings.clearCredentials), + ), + const Divider(height: 32), + TextField( + controller: _pollingController, + keyboardType: TextInputType.number, + decoration: InputDecoration(labelText: widget.strings.pollingInterval), + onSubmitted: (_) => _applyPollingInterval(), + ), + const SizedBox(height: 8), + ElevatedButton( + onPressed: _applyPollingInterval, + child: Text(widget.strings.pollingInterval), + ), + const Divider(height: 32), + _ThemeSelector(appState: widget.appState, strings: widget.strings), + const Divider(height: 32), + _LanguageSelector(appState: widget.appState, strings: widget.strings), + ], + ); + } +} + +class _ThemeSelector extends StatelessWidget { + const _ThemeSelector({required this.appState, required this.strings}); + + final AppState appState; + final AppStrings strings; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(strings.theme, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + DropdownButton( + value: appState.themeMode, + onChanged: (value) { + if (value != null) { + appState.setThemeMode(value); + } + }, + items: [ + DropdownMenuItem( + value: ThemeMode.system, + child: Text(strings.selectThemeSystem), + ), + DropdownMenuItem( + value: ThemeMode.light, + child: Text(strings.selectThemeLight), + ), + DropdownMenuItem( + value: ThemeMode.dark, + child: Text(strings.selectThemeDark), + ), + ], + ), + ], + ); + } +} + +class _LanguageSelector extends StatelessWidget { + const _LanguageSelector({required this.appState, required this.strings}); + + final AppState appState; + final AppStrings strings; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(strings.language, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + DropdownButton( + value: appState.language, + onChanged: (value) { + if (value != null) { + appState.setLanguage(value); + } + }, + items: [ + DropdownMenuItem( + value: AppLanguage.japanese, + child: Text(strings.selectLanguageJapanese), + ), + DropdownMenuItem( + value: AppLanguage.english, + child: Text(strings.selectLanguageEnglish), + ), + ], + ), + ], + ); + } +} diff --git a/flutter_app/lib/services/switchbot_api.dart b/flutter_app/lib/services/switchbot_api.dart new file mode 100644 index 0000000..5ad2770 --- /dev/null +++ b/flutter_app/lib/services/switchbot_api.dart @@ -0,0 +1,124 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:http/http.dart' as http; + +import '../models.dart'; + +class SwitchBotApi { + SwitchBotApi({http.Client? client}) : _client = client ?? http.Client(); + + final http.Client _client; + final Uri _baseUri = Uri.parse('https://api.switch-bot.com/v1.1'); + + Map _buildHeaders({ + required String token, + required String secret, + }) { + final timestamp = DateTime.now().millisecondsSinceEpoch.toString(); + final sign = _generateSign(secret: secret, token: token, timestamp: timestamp); + return { + 'Authorization': token, + 'sign': sign, + 't': timestamp, + 'Content-Type': 'application/json', + }; + } + + String _generateSign({ + required String secret, + required String token, + required String timestamp, + }) { + final key = utf8.encode(secret); + final data = utf8.encode('$token$timestamp'); + final hmacSha256 = Hmac(sha256, key); + return base64Encode(hmacSha256.convert(data).bytes); + } + + Future> fetchDevices({ + required String token, + required String secret, + }) async { + final response = await _client.get( + _baseUri.replace(path: '/v1.1/devices'), + headers: _buildHeaders(token: token, secret: secret), + ); + final payload = _parseResponse(response); + final deviceList = payload['deviceList'] as List? ?? []; + return deviceList + .map((device) => Device.fromJson(device as Map)) + .toList(); + } + + Future fetchDeviceStatus({ + required String token, + required String secret, + required String deviceId, + }) async { + final response = await _client.get( + _baseUri.replace(path: '/v1.1/devices/$deviceId/status'), + headers: _buildHeaders(token: token, secret: secret), + ); + final payload = _parseResponse(response); + return DeviceStatus.fromJson(payload); + } + + Future> fetchScenes({ + required String token, + required String secret, + }) async { + final response = await _client.get( + _baseUri.replace(path: '/v1.1/scenes'), + headers: _buildHeaders(token: token, secret: secret), + ); + final payload = _parseResponse(response); + final sceneList = payload['sceneList'] as List? ?? []; + return sceneList + .map((scene) => Scene.fromJson(scene as Map)) + .toList(); + } + + Future executeScene({ + required String token, + required String secret, + required String sceneId, + }) async { + final response = await _client.post( + _baseUri.replace(path: '/v1.1/scenes/$sceneId/execute'), + headers: _buildHeaders(token: token, secret: secret), + ); + _parseResponse(response); + } + + Future sendCommand({ + required String token, + required String secret, + required String deviceId, + required String command, + required String commandType, + String? parameter, + }) async { + final response = await _client.post( + _baseUri.replace(path: '/v1.1/devices/$deviceId/commands'), + headers: _buildHeaders(token: token, secret: secret), + body: jsonEncode({ + 'command': command, + 'parameter': parameter ?? 'default', + 'commandType': commandType, + }), + ); + _parseResponse(response); + } + + Map _parseResponse(http.Response response) { + final decoded = jsonDecode(response.body) as Map; + if (response.statusCode >= 400) { + throw Exception('HTTP ${response.statusCode}: ${response.body}'); + } + if (decoded['statusCode'] != 100) { + throw Exception(decoded['message'] ?? 'API error'); + } + return decoded['body'] as Map? ?? {}; + } +} diff --git a/flutter_app/pubspec.yaml b/flutter_app/pubspec.yaml new file mode 100644 index 0000000..ad284b3 --- /dev/null +++ b/flutter_app/pubspec.yaml @@ -0,0 +1,17 @@ +name: switchbot_controller_flutter +description: SwitchBot Controller Flutter client +publish_to: 'none' +version: 0.1.0+1 + +environment: + sdk: '>=3.3.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + http: ^1.2.2 + shared_preferences: ^2.3.2 + crypto: ^3.0.5 + +flutter: + uses-material-design: true