ChatServer

This commit is contained in:
joe 2026-03-01 20:45:29 +09:00
parent 3ddd56760a
commit a569f54b0b
14 changed files with 937 additions and 11 deletions

View file

@ -85,6 +85,34 @@
---
## 母艦「お局様」LAN サーバの起動
1. Dart/Flutter SDK が入った Linux / AndroidTermux 等)端末でリポジトリを取得
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://<host>:<port>/` を開くとステータス一覧を閲覧できます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 は **機能追加・アーキテクチャ変更・モジュール構成の見直し時に必ず更新** します。

View file

@ -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<void> main(List<String> 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 {

View file

@ -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<MyApp> createState() => _MyAppState();
@ -36,6 +42,25 @@ class MyApp extends StatefulWidget {
class _MyAppState extends State<MyApp> {
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) {

View file

@ -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,
);
}
}

View file

@ -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<String, dynamic> toJson() => {
'messageId': messageId,
'body': body,
'createdAt': createdAt.millisecondsSinceEpoch,
};
factory ChatEnvelope.fromJson(Map<String, dynamic> 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<void> init() async {
if (!await rootDir.exists()) {
await rootDir.create(recursive: true);
}
}
Future<void> appendInbound(String clientId, List<ChatEnvelope> 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<List<ChatEnvelope>> 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<dynamic>;
return decoded.map((e) => ChatEnvelope.fromJson(Map<String, dynamic>.from(e as Map))).toList();
} catch (_) {
return [];
}
}
Future<void> enqueueOutbound(String clientId, List<ChatEnvelope> 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<void> acknowledge(String clientId, List<String> 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<File> _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<File> _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');
}
}

View file

@ -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<HttpServer> 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<Response> _handleChatSend(Request request) async {
final body = await request.readAsString();
final json = jsonDecode(body) as Map<String, dynamic>;
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>()
.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<Response> _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<Response> _handleChatAck(Request request) async {
final body = await request.readAsString();
final json = jsonDecode(body) as Map<String, dynamic>;
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<String>() ?? [];
await chatStore.acknowledge(clientId, delivered);
return Response.ok('ok');
}
Future<Response> _handleStatus(Request request) async {
final status = dataStore.listStatuses().map((e) => e.toJson()).toList();
return Response.ok(jsonEncode({'clients': status}), headers: {'content-type': 'application/json'});

View file

@ -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<ChatScreen> createState() => _ChatScreenState();
}
class _ChatScreenState extends State<ChatScreen> {
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<ChatMessage> _messages = [];
@override
void initState() {
super.initState();
_mothershipClient = MothershipClient();
_chatClient = MothershipChatClient(repository: _repository, baseClient: _mothershipClient);
_refreshMessages();
}
Future<void> _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<void> _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),
),
],
),
),
),
],
);
}
}

View file

@ -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<SettingsScreen> {
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()));
},
),
],
),
],
),

View file

@ -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<Database> _db() => _dbHelper.database;
Future<List<ChatMessage>> 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<void> 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<void> 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<List<ChatMessage>> 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<void> markSynced(List<String> 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<String, dynamic> 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,
);
}
}

View file

@ -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;
}
}
}

View file

@ -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<void> _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<void> _safeAddColumn(Database db, String table, String columnDef) async {

View file

@ -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<void> sync() async {
await Future.wait([_pushPending(), _fetchInbound()]);
}
Future<void> _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<void> _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<String, dynamic>;
final messages = (decoded['messages'] as List?) ?? [];
final ackIds = <String>[];
for (final raw in messages.cast<Map>()) {
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<void> _ack(MothershipEndpointConfig config, String clientId, List<String> 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();
}
}
}

View file

@ -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<void> 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<void> 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<MothershipEndpointConfig?> 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<String> 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<void> _postJson({
required Uri uri,
required String apiKey,
required Map<String, dynamic> 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;
}

View file

@ -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