diff --git a/README.md b/README.md index 3658745..d4b018b 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,34 @@ --- +## 母艦「お局様」LAN サーバの起動 + +1. Dart/Flutter SDK が入った Linux / Android(Termux 等)端末でリポジトリを取得 +2. 監視サーバを起動 + ```bash + dart run bin/mothership_server.dart + ``` + - 環境変数 `MOTHERSHIP_HOST`, `MOTHERSHIP_PORT`, `MOTHERSHIP_API_KEY`, `MOTHERSHIP_DATA_DIR` で上書き可能 + - 既定値: `0.0.0.0:8787`, API キー `TEST_MOTHERSHIP_KEY`, 保存先 `data/mothership` + - `data/mothership/status.json` に各クライアントの心拍/ハッシュを保存 +3. ブラウザで `http://:/` を開くとステータス一覧を閲覧できます(CUI 常駐で OK) + +### クライアント(販売アシスト1号)からの接続設定 + +1. アプリの `S1:設定` → 「外部同期(母艦システム『お局様』連携)」で以下を入力 + - ホストドメイン: `http://192.168.0.10:8787` のようにプロトコル付きで指定 + - パスワード: サーバ側 API キー(例: `TEST_MOTHERSHIP_KEY`) +2. 保存するとアプリ起動時に `POST /sync/heartbeat` が自動送信され、寿命残時間が母艦に表示されます。 +3. 同じ設定でチャット送受信・ハッシュ送信が有効になります(下記参照)。 + +### チャット同期(最小構成) + +- Flutter アプリ側では 10 秒間隔の軽量ポーリングをバックグラウンドで実行し、`/chat/send` / `/chat/pending` / `/chat/ack` とローカル SQLite を同期します。 +- 設定画面からチャット画面を開かなくても新着が取り込まれ、開いた瞬間に最新ログが表示されます。 +- 端末がスリープに入るとポーリングを停止し、アプリが前面に戻ったタイミングで即時同期→再開します。 + +--- + ## 更新ポリシー - README は **機能追加・アーキテクチャ変更・モジュール構成の見直し時に必ず更新** します。 diff --git a/bin/mothership_server.dart b/bin/mothership_server.dart index 5ab9c32..d68b2e9 100644 --- a/bin/mothership_server.dart +++ b/bin/mothership_server.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:h_1/mothership/chat_store.dart'; import 'package:h_1/mothership/config.dart'; import 'package:h_1/mothership/data_store.dart'; import 'package:h_1/mothership/server.dart'; @@ -8,7 +9,9 @@ Future main(List args) async { final config = MothershipConfig.fromEnv(); final dataStore = MothershipDataStore(config.dataDirectory); await dataStore.init(); - final server = MothershipServer(config: config, dataStore: dataStore); + final chatStore = MothershipChatStore(config.dataDirectory); + await chatStore.init(); + final server = MothershipServer(config: config, dataStore: dataStore, chatStore: chatStore); final httpServer = await server.start(); stdout.writeln('Mothership listening on http://${config.host}:${config.port}'); ProcessSignal.sigint.watch().listen((_) async { diff --git a/lib/main.dart b/lib/main.dart index 3b97cc5..4b65b56 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,5 +1,7 @@ // lib/main.dart // version: 1.5.02 (Update: Date selection & Tax fix) +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -12,6 +14,8 @@ import 'screens/dashboard_screen.dart'; // ダッシュボード import 'services/location_service.dart'; // 位置情報サービス import 'services/customer_repository.dart'; // 顧客リポジトリ import 'services/app_settings_repository.dart'; +import 'services/chat_sync_scheduler.dart'; +import 'services/mothership_client.dart'; import 'services/theme_controller.dart'; import 'utils/build_expiry_info.dart'; @@ -23,11 +27,13 @@ void main() async { runApp(ExpiredApp(expiryInfo: expiryInfo)); return; } - runApp(const MyApp()); + runApp(MyApp(expiryInfo: expiryInfo)); } class MyApp extends StatefulWidget { - const MyApp({super.key}); + const MyApp({super.key, required this.expiryInfo}); + + final BuildExpiryInfo expiryInfo; @override State createState() => _MyAppState(); @@ -36,6 +42,25 @@ class MyApp extends StatefulWidget { class _MyAppState extends State { final TransformationController _zoomController = TransformationController(); int _activePointers = 0; + final MothershipClient _mothershipClient = MothershipClient(); + final ChatSyncScheduler _chatSyncScheduler = ChatSyncScheduler(); + + @override + void initState() { + super.initState(); + _sendHeartbeat(); + _chatSyncScheduler.start(); + } + + @override + void dispose() { + _chatSyncScheduler.dispose(); + super.dispose(); + } + + void _sendHeartbeat() { + Future.microtask(() => _mothershipClient.sendHeartbeat(widget.expiryInfo)); + } @override Widget build(BuildContext context) { diff --git a/lib/models/chat_message.dart b/lib/models/chat_message.dart new file mode 100644 index 0000000..7b05589 --- /dev/null +++ b/lib/models/chat_message.dart @@ -0,0 +1,45 @@ +enum ChatDirection { outbound, inbound } + +class ChatMessage { + ChatMessage({ + this.id, + required this.messageId, + required this.clientId, + required this.direction, + required this.body, + required this.createdAt, + this.synced = true, + this.deliveredAt, + }); + + final int? id; + final String messageId; + final String clientId; + final ChatDirection direction; + final String body; + final DateTime createdAt; + final bool synced; + final DateTime? deliveredAt; + + ChatMessage copyWith({ + int? id, + String? messageId, + String? clientId, + ChatDirection? direction, + String? body, + DateTime? createdAt, + bool? synced, + DateTime? deliveredAt, + }) { + return ChatMessage( + id: id ?? this.id, + messageId: messageId ?? this.messageId, + clientId: clientId ?? this.clientId, + direction: direction ?? this.direction, + body: body ?? this.body, + createdAt: createdAt ?? this.createdAt, + synced: synced ?? this.synced, + deliveredAt: deliveredAt ?? this.deliveredAt, + ); + } +} diff --git a/lib/mothership/chat_store.dart b/lib/mothership/chat_store.dart new file mode 100644 index 0000000..3daab70 --- /dev/null +++ b/lib/mothership/chat_store.dart @@ -0,0 +1,97 @@ +import 'dart:convert'; +import 'dart:io'; + +class ChatEnvelope { + ChatEnvelope({required this.messageId, required this.body, required this.createdAt}); + + final String messageId; + final String body; + final DateTime createdAt; + + Map toJson() => { + 'messageId': messageId, + 'body': body, + 'createdAt': createdAt.millisecondsSinceEpoch, + }; + + factory ChatEnvelope.fromJson(Map json) { + return ChatEnvelope( + messageId: json['messageId'] as String, + body: json['body'] as String, + createdAt: DateTime.fromMillisecondsSinceEpoch(json['createdAt'] as int, isUtc: true), + ); + } +} + +class MothershipChatStore { + MothershipChatStore(this.rootDir); + + final Directory rootDir; + + Future init() async { + if (!await rootDir.exists()) { + await rootDir.create(recursive: true); + } + } + + Future appendInbound(String clientId, List messages) async { + if (messages.isEmpty) return; + final file = await _logFile(clientId); + final sink = file.openWrite(mode: FileMode.append); + for (final message in messages) { + sink.writeln(jsonEncode(message.toJson())); + } + await sink.flush(); + await sink.close(); + } + + Future> pendingOutbound(String clientId) async { + final file = await _outboxFile(clientId); + if (!await file.exists()) return []; + try { + final raw = await file.readAsString(); + if (raw.trim().isEmpty) return []; + final decoded = jsonDecode(raw) as List; + return decoded.map((e) => ChatEnvelope.fromJson(Map.from(e as Map))).toList(); + } catch (_) { + return []; + } + } + + Future enqueueOutbound(String clientId, List messages) async { + if (messages.isEmpty) return; + final current = await pendingOutbound(clientId); + final combined = [...current, ...messages]; + final file = await _outboxFile(clientId); + await file.writeAsString(jsonEncode(combined.map((e) => e.toJson()).toList())); + } + + Future acknowledge(String clientId, List messageIds) async { + if (messageIds.isEmpty) return; + final file = await _outboxFile(clientId); + if (!await file.exists()) return; + final current = await pendingOutbound(clientId); + final filtered = current.where((m) => !messageIds.contains(m.messageId)).toList(); + if (filtered.isEmpty) { + await file.delete(); + } else { + await file.writeAsString(jsonEncode(filtered.map((e) => e.toJson()).toList())); + } + } + + Future _logFile(String clientId) async { + final dir = Directory('${rootDir.path}/$clientId'); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return File('${dir.path}/log.jsonl'); + } + + Future _outboxFile(String clientId) async { + final dir = Directory('${rootDir.path}/$clientId'); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return File('${dir.path}/outbox.json'); + } +} diff --git a/lib/mothership/server.dart b/lib/mothership/server.dart index 5e81cb4..9d19a2b 100644 --- a/lib/mothership/server.dart +++ b/lib/mothership/server.dart @@ -5,19 +5,24 @@ import 'package:shelf/shelf.dart'; import 'package:shelf/shelf_io.dart'; import 'package:shelf_router/shelf_router.dart'; +import 'chat_store.dart'; import 'config.dart'; import 'data_store.dart'; class MothershipServer { - MothershipServer({required this.config, required this.dataStore}); + MothershipServer({required this.config, required this.dataStore, required this.chatStore}); final MothershipConfig config; final MothershipDataStore dataStore; + final MothershipChatStore chatStore; Future start() async { final router = Router() ..post('/sync/heartbeat', _handleHeartbeat) ..post('/sync/hash', _handleHash) + ..post('/chat/send', _handleChatSend) + ..get('/chat/pending', _handleChatPending) + ..post('/chat/ack', _handleChatAck) ..get('/status', _handleStatus) ..get('/', _handleDashboard); @@ -72,6 +77,48 @@ class MothershipServer { return Response.ok('ok'); } + Future _handleChatSend(Request request) async { + final body = await request.readAsString(); + final json = jsonDecode(body) as Map; + final clientId = json['clientId'] as String?; + if (clientId == null || clientId.isEmpty) { + return Response(400, body: 'clientId is required'); + } + final messages = (json['messages'] as List?) ?? []; + final envelopes = messages + .whereType() + .map((e) => ChatEnvelope( + messageId: e['messageId'] as String, + body: e['body'] as String, + createdAt: DateTime.fromMillisecondsSinceEpoch((e['createdAt'] as int?) ?? 0, isUtc: true), + )) + .toList(); + await chatStore.appendInbound(clientId, envelopes); + return Response.ok(jsonEncode({'stored': envelopes.length}), headers: {'content-type': 'application/json'}); + } + + Future _handleChatPending(Request request) async { + final clientId = request.url.queryParameters['clientId']; + if (clientId == null || clientId.isEmpty) { + return Response(400, body: 'clientId is required'); + } + final messages = await chatStore.pendingOutbound(clientId); + final payload = {'messages': messages.map((e) => e.toJson()).toList()}; + return Response.ok(jsonEncode(payload), headers: {'content-type': 'application/json'}); + } + + Future _handleChatAck(Request request) async { + final body = await request.readAsString(); + final json = jsonDecode(body) as Map; + final clientId = json['clientId'] as String?; + if (clientId == null || clientId.isEmpty) { + return Response(400, body: 'clientId is required'); + } + final delivered = (json['delivered'] as List?)?.cast() ?? []; + await chatStore.acknowledge(clientId, delivered); + return Response.ok('ok'); + } + Future _handleStatus(Request request) async { final status = dataStore.listStatuses().map((e) => e.toJson()).toList(); return Response.ok(jsonEncode({'clients': status}), headers: {'content-type': 'application/json'}); diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart new file mode 100644 index 0000000..1074479 --- /dev/null +++ b/lib/screens/chat_screen.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; + +import '../models/chat_message.dart'; +import '../services/chat_repository.dart'; +import '../services/mothership_chat_client.dart'; +import '../services/mothership_client.dart'; + +class ChatScreen extends StatefulWidget { + const ChatScreen({super.key}); + + @override + State createState() => _ChatScreenState(); +} + +class _ChatScreenState extends State { + final ChatRepository _repository = ChatRepository(); + late final MothershipClient _mothershipClient; + late final MothershipChatClient _chatClient; + final TextEditingController _inputController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); + + bool _syncing = false; + bool _sending = false; + List _messages = []; + + @override + void initState() { + super.initState(); + _mothershipClient = MothershipClient(); + _chatClient = MothershipChatClient(repository: _repository, baseClient: _mothershipClient); + _refreshMessages(); + } + + Future _refreshMessages() async { + setState(() => _syncing = true); + await _chatClient.sync(); + final list = await _repository.listMessages(); + if (!mounted) return; + setState(() { + _messages = list; + _syncing = false; + }); + _scrollToBottom(); + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_scrollController.hasClients) return; + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ); + }); + } + + Future _sendMessage() async { + if (_sending) return; + final text = _inputController.text.trim(); + if (text.isEmpty) return; + setState(() => _sending = true); + final clientId = await _mothershipClient.ensureClientId(); + await _repository.addOutbound(clientId: clientId, body: text); + _inputController.clear(); + await _chatClient.sync(); + final list = await _repository.listMessages(); + if (!mounted) return; + setState(() { + _messages = list; + _sending = false; + }); + _scrollToBottom(); + } + + @override + void dispose() { + _inputController.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar( + title: const Text('母艦チャット'), + actions: [ + IconButton( + tooltip: '再同期', + onPressed: _syncing ? null : _refreshMessages, + icon: _syncing ? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.refresh), + ), + ], + ), + body: Column( + children: [ + Expanded( + child: Container( + color: theme.colorScheme.surface, + child: _messages.isEmpty + ? Center( + child: Text( + _syncing ? '同期中...' : 'まだメッセージはありません', + style: theme.textTheme.bodyMedium, + ), + ) + : ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), + itemCount: _messages.length, + itemBuilder: (context, index) => _MessageBubble(message: _messages[index]), + ), + ), + ), + const Divider(height: 1), + SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: _inputController, + minLines: 1, + maxLines: 4, + decoration: const InputDecoration( + hintText: 'メッセージを入力', + ), + ), + ), + const SizedBox(width: 12), + ElevatedButton.icon( + onPressed: _sending ? null : _sendMessage, + icon: const Icon(Icons.send), + label: const Text('送信'), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +class _MessageBubble extends StatelessWidget { + const _MessageBubble({required this.message}); + + final ChatMessage message; + + @override + Widget build(BuildContext context) { + final isOutbound = message.direction == ChatDirection.outbound; + final theme = Theme.of(context); + final bubbleColor = isOutbound ? theme.colorScheme.primary : Colors.grey.shade200; + final textColor = isOutbound ? Colors.white : Colors.grey.shade900; + final align = isOutbound ? CrossAxisAlignment.end : CrossAxisAlignment.start; + final borderRadius = BorderRadius.only( + topLeft: const Radius.circular(16), + topRight: const Radius.circular(16), + bottomLeft: Radius.circular(isOutbound ? 16 : 4), + bottomRight: Radius.circular(isOutbound ? 4 : 16), + ); + final timeText = TimeOfDay.fromDateTime(message.createdAt.toLocal()).format(context); + + return Column( + crossAxisAlignment: align, + children: [ + Align( + alignment: isOutbound ? Alignment.centerRight : Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + constraints: const BoxConstraints(maxWidth: 320), + decoration: BoxDecoration( + color: bubbleColor, + borderRadius: borderRadius, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: align, + children: [ + Text( + message.body, + style: theme.textTheme.bodyMedium?.copyWith(color: textColor), + ), + const SizedBox(height: 6), + Text( + timeText, + style: theme.textTheme.labelSmall?.copyWith(color: textColor.withOpacity(0.8), fontSize: 11), + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 8e88c29..dabf63f 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,12 +1,14 @@ -import 'dart:io'; -import 'package:flutter/material.dart'; import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import '../services/app_settings_repository.dart'; import '../services/theme_controller.dart'; import 'company_info_screen.dart'; import 'email_settings_screen.dart'; import 'business_profile_screen.dart'; +import 'chat_screen.dart'; class SettingsScreen extends StatefulWidget { const SettingsScreen({super.key}); @@ -427,10 +429,22 @@ class _SettingsScreenState extends State { TextField(controller: _externalHostCtrl, decoration: const InputDecoration(labelText: 'ホストドメイン')), TextField(controller: _externalPassCtrl, decoration: const InputDecoration(labelText: 'パスワード'), obscureText: true), const SizedBox(height: 8), - ElevatedButton.icon( - icon: const Icon(Icons.save), - label: const Text('保存'), - onPressed: _saveExternalSync, + Row( + children: [ + ElevatedButton.icon( + icon: const Icon(Icons.save), + label: const Text('保存'), + onPressed: _saveExternalSync, + ), + const SizedBox(width: 12), + OutlinedButton.icon( + icon: const Icon(Icons.chat_bubble_outline), + label: const Text('チャットを開く'), + onPressed: () async { + await Navigator.push(context, MaterialPageRoute(builder: (_) => const ChatScreen())); + }, + ), + ], ), ], ), diff --git a/lib/services/chat_repository.dart b/lib/services/chat_repository.dart new file mode 100644 index 0000000..831d675 --- /dev/null +++ b/lib/services/chat_repository.dart @@ -0,0 +1,88 @@ +import 'package:sqflite/sqflite.dart'; +import 'package:uuid/uuid.dart'; + +import '../models/chat_message.dart'; +import 'database_helper.dart'; + +class ChatRepository { + ChatRepository(); + + final DatabaseHelper _dbHelper = DatabaseHelper(); + final _uuid = const Uuid(); + + Future _db() => _dbHelper.database; + + Future> listMessages({int limit = 200}) async { + final db = await _db(); + final rows = await db.query( + 'chat_messages', + orderBy: 'created_at DESC', + limit: limit, + ); + return rows.map(_fromRow).toList().reversed.toList(); + } + + Future addOutbound({required String clientId, required String body}) async { + final db = await _db(); + final now = DateTime.now().toUtc(); + await db.insert( + 'chat_messages', + { + 'message_id': _uuid.v4(), + 'client_id': clientId, + 'direction': 'outbound', + 'body': body, + 'created_at': now.millisecondsSinceEpoch, + 'synced': 0, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + Future upsertInbound(ChatMessage message) async { + final db = await _db(); + await db.insert( + 'chat_messages', + { + 'message_id': message.messageId, + 'client_id': message.clientId, + 'direction': 'inbound', + 'body': message.body, + 'created_at': message.createdAt.millisecondsSinceEpoch, + 'synced': 1, + 'delivered_at': DateTime.now().toUtc().millisecondsSinceEpoch, + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + Future> pendingOutbound() async { + final db = await _db(); + final rows = await db.query('chat_messages', where: 'direction = ? AND synced = 0', whereArgs: ['outbound'], orderBy: 'created_at ASC'); + return rows.map(_fromRow).toList(); + } + + Future markSynced(List messageIds) async { + if (messageIds.isEmpty) return; + final db = await _db(); + await db.update( + 'chat_messages', + {'synced': 1}, + where: 'message_id IN (${List.filled(messageIds.length, '?').join(',')})', + whereArgs: messageIds, + ); + } + + ChatMessage _fromRow(Map row) { + return ChatMessage( + id: row['id'] as int?, + messageId: row['message_id'] as String, + clientId: row['client_id'] as String, + direction: (row['direction'] as String) == 'outbound' ? ChatDirection.outbound : ChatDirection.inbound, + body: row['body'] as String, + createdAt: DateTime.fromMillisecondsSinceEpoch(row['created_at'] as int, isUtc: true), + synced: (row['synced'] as int? ?? 1) == 1, + deliveredAt: row['delivered_at'] != null ? DateTime.fromMillisecondsSinceEpoch(row['delivered_at'] as int, isUtc: true) : null, + ); + } +} diff --git a/lib/services/chat_sync_scheduler.dart b/lib/services/chat_sync_scheduler.dart new file mode 100644 index 0000000..8f8bac0 --- /dev/null +++ b/lib/services/chat_sync_scheduler.dart @@ -0,0 +1,68 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; + +import 'mothership_chat_client.dart'; + +class ChatSyncScheduler with WidgetsBindingObserver { + ChatSyncScheduler({Duration? interval}) : _interval = interval ?? const Duration(seconds: 10); + + final Duration _interval; + final MothershipChatClient _chatClient = MothershipChatClient(); + + Timer? _timer; + bool _started = false; + bool _syncing = false; + bool _appActive = true; + + void start() { + if (_started) return; + _started = true; + final binding = WidgetsBinding.instance; + binding.addObserver(this); + _appActive = _isActiveState(binding.lifecycleState); + if (_appActive) { + _scheduleImmediate(); + } + } + + void stop() { + if (!_started) return; + WidgetsBinding.instance.removeObserver(this); + _timer?.cancel(); + _timer = null; + _started = false; + } + + void dispose() => stop(); + + void _scheduleImmediate() { + _timer?.cancel(); + _runSync(); + _timer = Timer.periodic(_interval, (_) => _runSync()); + } + + void _runSync() { + if (!_appActive || _syncing) return; + _syncing = true; + unawaited(_chatClient.sync().whenComplete(() { + _syncing = false; + })); + } + + bool _isActiveState(AppLifecycleState? state) { + return state == null || state == AppLifecycleState.resumed; + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + _appActive = _isActiveState(state); + if (!_started) return; + if (_appActive) { + _scheduleImmediate(); + } else { + _timer?.cancel(); + _timer = null; + } + } +} diff --git a/lib/services/database_helper.dart b/lib/services/database_helper.dart index 974bc23..fc55a85 100644 --- a/lib/services/database_helper.dart +++ b/lib/services/database_helper.dart @@ -2,7 +2,7 @@ import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart'; class DatabaseHelper { - static const _databaseVersion = 25; + static const _databaseVersion = 26; static final DatabaseHelper _instance = DatabaseHelper._internal(); static Database? _database; @@ -195,6 +195,21 @@ class DatabaseHelper { await _safeAddColumn(db, 'invoices', 'meta_json TEXT'); await _safeAddColumn(db, 'invoices', 'meta_hash TEXT'); } + if (oldVersion < 26) { + await db.execute(''' + CREATE TABLE IF NOT EXISTS chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id TEXT UNIQUE NOT NULL, + client_id TEXT NOT NULL, + direction TEXT NOT NULL, + body TEXT NOT NULL, + created_at INTEGER NOT NULL, + synced INTEGER DEFAULT 0, + delivered_at INTEGER + ) + '''); + await db.execute('CREATE INDEX IF NOT EXISTS idx_chat_messages_created_at ON chat_messages(created_at)'); + } } Future _onCreate(Database db, int version) async { @@ -359,6 +374,20 @@ class DatabaseHelper { value TEXT ) '''); + + await db.execute(''' + CREATE TABLE chat_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + message_id TEXT UNIQUE NOT NULL, + client_id TEXT NOT NULL, + direction TEXT NOT NULL, + body TEXT NOT NULL, + created_at INTEGER NOT NULL, + synced INTEGER DEFAULT 0, + delivered_at INTEGER + ) + '''); + await db.execute('CREATE INDEX idx_chat_messages_created_at ON chat_messages(created_at)'); } Future _safeAddColumn(Database db, String table, String columnDef) async { diff --git a/lib/services/mothership_chat_client.dart b/lib/services/mothership_chat_client.dart new file mode 100644 index 0000000..06d270b --- /dev/null +++ b/lib/services/mothership_chat_client.dart @@ -0,0 +1,120 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; + +import '../models/chat_message.dart'; +import 'chat_repository.dart'; +import 'mothership_client.dart'; + +class MothershipChatClient { + MothershipChatClient({ChatRepository? repository, MothershipClient? baseClient, http.Client? httpClient}) + : _repository = repository ?? ChatRepository(), + _baseClient = baseClient ?? MothershipClient(), + _httpClient = httpClient; + + final ChatRepository _repository; + final MothershipClient _baseClient; + final http.Client? _httpClient; + + Future sync() async { + await Future.wait([_pushPending(), _fetchInbound()]); + } + + Future _pushPending() async { + final config = await _baseClient.loadConfig(); + if (config == null) { + debugPrint('[ChatSync] skip push: config missing'); + return; + } + final clientId = await _baseClient.ensureClientId(); + final pending = await _repository.pendingOutbound(); + if (pending.isEmpty) return; + final client = _httpClient ?? http.Client(); + try { + final payload = { + 'clientId': clientId, + 'messages': pending + .map((m) => { + 'messageId': m.messageId, + 'body': m.body, + 'createdAt': m.createdAt.millisecondsSinceEpoch, + }) + .toList(), + }; + final res = await client.post( + config.chatSendUri, + headers: { + 'content-type': 'application/json', + 'x-api-key': config.apiKey, + }, + body: jsonEncode(payload), + ); + if (res.statusCode >= 200 && res.statusCode < 300) { + await _repository.markSynced(pending.map((e) => e.messageId).toList()); + debugPrint('[ChatSync] pushed ${pending.length} msgs'); + } else { + debugPrint('[ChatSync] push failed ${res.statusCode} ${res.body}'); + } + } catch (err) { + debugPrint('[ChatSync] push error $err'); + } finally { + if (_httpClient == null) client.close(); + } + } + + Future _fetchInbound() async { + final config = await _baseClient.loadConfig(); + if (config == null) return; + final clientId = await _baseClient.ensureClientId(); + final client = _httpClient ?? http.Client(); + try { + final uri = config.chatPendingUri.replace(queryParameters: {'clientId': clientId}); + final res = await client.get(uri, headers: {'x-api-key': config.apiKey}); + if (res.statusCode >= 200 && res.statusCode < 300) { + final decoded = jsonDecode(res.body) as Map; + final messages = (decoded['messages'] as List?) ?? []; + final ackIds = []; + for (final raw in messages.cast()) { + final msg = ChatMessage( + messageId: raw['messageId'] as String, + clientId: clientId, + direction: ChatDirection.inbound, + body: raw['body'] as String, + createdAt: DateTime.fromMillisecondsSinceEpoch((raw['createdAt'] as int?) ?? 0, isUtc: true), + synced: true, + ); + await _repository.upsertInbound(msg); + ackIds.add(msg.messageId); + } + if (ackIds.isNotEmpty) { + await _ack(config, clientId, ackIds); + } + } else { + debugPrint('[ChatSync] fetch failed ${res.statusCode} ${res.body}'); + } + } catch (err) { + debugPrint('[ChatSync] fetch error $err'); + } finally { + if (_httpClient == null) client.close(); + } + } + + Future _ack(MothershipEndpointConfig config, String clientId, List ids) async { + final client = _httpClient ?? http.Client(); + try { + await client.post( + config.chatAckUri, + headers: { + 'content-type': 'application/json', + 'x-api-key': config.apiKey, + }, + body: jsonEncode({'clientId': clientId, 'delivered': ids}), + ); + } catch (err) { + debugPrint('[ChatSync] ack error $err'); + } finally { + if (_httpClient == null) client.close(); + } + } +} diff --git a/lib/services/mothership_client.dart b/lib/services/mothership_client.dart new file mode 100644 index 0000000..76f3c9d --- /dev/null +++ b/lib/services/mothership_client.dart @@ -0,0 +1,153 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:uuid/uuid.dart'; + +import '../utils/build_expiry_info.dart'; +import 'app_settings_repository.dart'; + +class MothershipClient { + MothershipClient({AppSettingsRepository? settingsRepository, http.Client? httpClient}) + : _settingsRepository = settingsRepository ?? AppSettingsRepository(), + _httpClient = httpClient; + + final AppSettingsRepository _settingsRepository; + final http.Client? _httpClient; + + static const _clientIdKey = 'mothership_client_id'; + static const _hostSettingKey = 'external_host'; + static const _apiKeySettingKey = 'external_pass'; + + Future sendHeartbeat(BuildExpiryInfo expiryInfo) async { + final config = await loadConfig(); + if (config == null) { + debugPrint('[Mothership] Heartbeat skipped: config not set'); + return; + } + final clientId = await ensureClientId(); + final remaining = expiryInfo.remaining?.inSeconds; + await _postJson( + uri: config.heartbeatUri, + apiKey: config.apiKey, + payload: { + 'clientId': clientId, + if (remaining != null) 'remainingLifespanSeconds': remaining, + }, + logLabel: 'heartbeat', + ); + } + + Future sendHash(String hash) async { + final config = await loadConfig(); + if (config == null) { + debugPrint('[Mothership] Hash push skipped: config not set'); + return; + } + final clientId = await ensureClientId(); + await _postJson( + uri: config.hashUri, + apiKey: config.apiKey, + payload: { + 'clientId': clientId, + 'hash': hash, + }, + logLabel: 'hash', + ); + } + + Future loadConfig() async { + final host = (await _settingsRepository.getString(_hostSettingKey))?.trim(); + final apiKey = (await _settingsRepository.getString(_apiKeySettingKey))?.trim(); + if (host == null || host.isEmpty || apiKey == null || apiKey.isEmpty) { + return null; + } + try { + final base = _normalizeBaseUri(host); + return MothershipEndpointConfig( + apiKey: apiKey, + heartbeatUri: base.resolve('/sync/heartbeat'), + hashUri: base.resolve('/sync/hash'), + chatSendUri: base.resolve('/chat/send'), + chatPendingUri: base.resolve('/chat/pending'), + chatAckUri: base.resolve('/chat/ack'), + ); + } on FormatException catch (err) { + debugPrint('[Mothership] Invalid host "$host": $err'); + return null; + } + } + + Uri _normalizeBaseUri(String host) { + var normalized = host.trim(); + if (!normalized.startsWith('http://') && !normalized.startsWith('https://')) { + normalized = 'http://$normalized'; + } + if (!normalized.endsWith('/')) { + normalized = '$normalized/'; + } + return Uri.parse(normalized); + } + + Future ensureClientId() async { + final prefs = await SharedPreferences.getInstance(); + final existing = prefs.getString(_clientIdKey); + if (existing != null && existing.isNotEmpty) { + return existing; + } + final newId = const Uuid().v4(); + await prefs.setString(_clientIdKey, newId); + return newId; + } + + Future _postJson({ + required Uri uri, + required String apiKey, + required Map payload, + required String logLabel, + }) async { + final client = _httpClient ?? http.Client(); + try { + final response = await client.post( + uri, + headers: { + HttpHeaders.contentTypeHeader: 'application/json', + 'x-api-key': apiKey, + }, + body: jsonEncode(payload), + ); + if (response.statusCode >= 200 && response.statusCode < 300) { + debugPrint('[Mothership] $logLabel OK (${response.statusCode})'); + } else { + debugPrint('[Mothership] $logLabel failed: ${response.statusCode} ${response.body}'); + } + } catch (err, stack) { + debugPrint('[Mothership] $logLabel error: $err'); + debugPrint('$stack'); + } finally { + if (_httpClient == null) { + client.close(); + } + } + } +} + +class MothershipEndpointConfig { + MothershipEndpointConfig({ + required this.apiKey, + required this.heartbeatUri, + required this.hashUri, + required this.chatSendUri, + required this.chatPendingUri, + required this.chatAckUri, + }); + + final String apiKey; + final Uri heartbeatUri; + final Uri hashUri; + final Uri chatSendUri; + final Uri chatPendingUri; + final Uri chatAckUri; +} diff --git a/pubspec.yaml b/pubspec.yaml index bc631a7..78d10f4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -54,6 +54,7 @@ dependencies: shared_preferences: ^2.2.2 mailer: ^6.0.1 flutter_email_sender: ^6.0.3 + http: ^1.2.2 shelf: ^1.4.1 shelf_router: ^1.1.4