Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions flutter_app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.dart_tool/
.packages
build/
.pub/
.idea/
.vscode/
*.iml
18 changes: 18 additions & 0 deletions flutter_app/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# SwitchBot Controller Flutter

Flutter 版の SwitchBot Controller です。既存の Electron 版と同じ機能を提供することを目的としています。

## セットアップ
```bash
cd flutter_app
flutter pub get
flutter run
```

## 機能
- デバイス一覧表示・絞り込み・詳細のステータス表示
- デバイスコマンドの送信
- シーン一覧の取得と実行
- API トークン/シークレットの保存と検証
- ポーリング間隔、テーマ、言語の設定
- 最後に開いていたタブの復元
248 changes: 248 additions & 0 deletions flutter_app/lib/app_state.dart
Original file line number Diff line number Diff line change
@@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

ポーリング間隔のデフォルト値 10 がハードコードされています。この値をクラス定数として定義することで、コードの可読性と保守性が向上します。また、load メソッドでのデフォルト値の取得もその定数を使用するように変更してください。

Suggested change
int _pollingIntervalSeconds = 10;
static const int defaultPollingIntervalSeconds = 10;
int _pollingIntervalSeconds = defaultPollingIntervalSeconds;

ThemeMode _themeMode = ThemeMode.system;
AppLanguage _language = AppLanguage.japanese;
int _lastTabIndex = 0;

Timer? _pollingTimer;

List<Device> _devices = [];
List<Scene> _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<Device> get devices => _devices;
List<Scene> get scenes => _scenes;
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;

Future<void> load() async {
final prefs = await SharedPreferences.getInstance();
_token = prefs.getString('token') ?? '';
_secret = prefs.getString('secret') ?? '';
_pollingIntervalSeconds = prefs.getInt('pollingIntervalSeconds') ?? 10;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

ポーリング間隔のデフォルト値 10 がハードコードされています。この値をクラス定数として定義することで、コードの可読性と保守性が向上します。また、load メソッドでのデフォルト値の取得もその定数を使用するように変更してください。

Suggested change
_pollingIntervalSeconds = prefs.getInt('pollingIntervalSeconds') ?? 10;
_pollingIntervalSeconds = prefs.getInt('pollingIntervalSeconds') ?? AppState.defaultPollingIntervalSeconds;

_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<void> 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<void> clearCredentials() async {
_token = '';
_secret = '';
final prefs = await SharedPreferences.getInstance();
await prefs.remove('token');
await prefs.remove('secret');
notifyListeners();
}

Future<void> setPollingIntervalSeconds(int value) async {
_pollingIntervalSeconds = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('pollingIntervalSeconds', value);
notifyListeners();
_restartPolling();
}

Future<void> setThemeMode(ThemeMode value) async {
_themeMode = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('themeMode', value.index);
notifyListeners();
}

Future<void> setLanguage(AppLanguage value) async {
_language = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('language', value.index);
notifyListeners();
}

Future<void> setLastTabIndex(int index) async {
_lastTabIndex = index;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('lastTabIndex', index);
notifyListeners();
}

Future<bool> validateCredentials() async {
if (_token.isEmpty || _secret.isEmpty) {
_errorMessage = '認証情報が未入力です。';
notifyListeners();
return false;
Comment on lines +100 to +104

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

認証情報の空チェック (_token.isEmpty || _secret.isEmpty) とエラーメッセージの設定が複数の非同期メソッドで繰り返されています。このロジックをプライベートなヘルパーメソッドに抽出し、エラーメッセージを AppStrings を使用してローカライズすることで、コードの重複を減らし、保守性と多言語対応を向上させることができます。

}
try {
await api.fetchDevices(token: _token, secret: _secret);
_errorMessage = null;
notifyListeners();
return true;
} catch (error) {
_errorMessage = error.toString();
notifyListeners();
return false;
Comment on lines +109 to +114

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

validateCredentials メソッド内で _errorMessage の更新後に notifyListeners() が複数回呼び出されています。try-catch ブロック内で状態が変更された後、finally ブロックで一度だけ notifyListeners() を呼び出すことで、不必要なウィジェットの再構築を避けることができます。このパターンは他の非同期メソッド (refreshDevices, refreshScenes, fetchDeviceStatus, executeScene, sendCommand) にも適用できます。

      _errorMessage = null;
      return true;
    } catch (error) {
      _errorMessage = error.toString();
      return false;
    } finally {
      notifyListeners();
    }

}
}

Future<void> 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<void> 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<DeviceStatus?> 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<bool> 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<bool> 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();
}
}
43 changes: 43 additions & 0 deletions flutter_app/lib/l10n.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import 'models.dart';

class AppStrings {
AppStrings(this.language);

final AppLanguage language;

String get appTitle => _text('SwitchBot Controller', 'SwitchBot Controller');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

appTitle の文字列が英語と日本語で同じ 'SwitchBot Controller' に設定されています。もしアプリのタイトルもローカライズの対象であれば、日本語版のタイトルを別途設定するか、ローカライズが不要であれば _text メソッドを使用せずに直接文字列を返すように変更することを検討してください。

Suggested change
String get appTitle => _text('SwitchBot Controller', 'SwitchBot Controller');
String get appTitle => '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;
}
}
Loading