diff --git a/lib/mothership/server.dart b/lib/mothership/server.dart index 9d19a2b..01ddef3 100644 --- a/lib/mothership/server.dart +++ b/lib/mothership/server.dart @@ -29,7 +29,7 @@ class MothershipServer { final handler = const Pipeline() .addMiddleware(logRequests()) .addMiddleware(_apiKeyMiddleware(config.apiKey)) - .addHandler(router); + .addHandler(router.call); final server = await serve(handler, config.host, config.port); return server; diff --git a/lib/screens/chat_screen.dart b/lib/screens/chat_screen.dart index 1074479..e413fa8 100644 --- a/lib/screens/chat_screen.dart +++ b/lib/screens/chat_screen.dart @@ -180,7 +180,7 @@ class _MessageBubble extends StatelessWidget { borderRadius: borderRadius, boxShadow: [ BoxShadow( - color: Colors.black.withOpacity(0.05), + color: Colors.black.withValues(alpha: 0.05), blurRadius: 8, offset: const Offset(0, 4), ), @@ -196,7 +196,7 @@ class _MessageBubble extends StatelessWidget { const SizedBox(height: 6), Text( timeText, - style: theme.textTheme.labelSmall?.copyWith(color: textColor.withOpacity(0.8), fontSize: 11), + style: theme.textTheme.labelSmall?.copyWith(color: textColor.withValues(alpha: 0.8), fontSize: 11), ), ], ), diff --git a/lib/screens/dashboard_screen.dart b/lib/screens/dashboard_screen.dart index 5b0b14d..aa3b3cb 100644 --- a/lib/screens/dashboard_screen.dart +++ b/lib/screens/dashboard_screen.dart @@ -219,11 +219,12 @@ class _DashboardScreenState extends State { ) : SlideToUnlock( isLocked: !_historyUnlocked, + lockedText: 'スライドでロック解除 (A2)', + unlockedText: 'A2解除済', onUnlocked: () async { setState(() => _historyUnlocked = true); await _repo.setDashboardHistoryUnlocked(true); }, - text: 'スライドでロック解除 (A2)', ), ), if (_statusEnabled) diff --git a/lib/screens/invoice_history_screen.dart b/lib/screens/invoice_history_screen.dart index 0ca2b20..f923432 100644 --- a/lib/screens/invoice_history_screen.dart +++ b/lib/screens/invoice_history_screen.dart @@ -219,61 +219,72 @@ class _InvoiceHistoryScreenState extends State { drawer: (_useDashboardHome || !_isUnlocked) ? null : Drawer( - child: ListView( - padding: EdgeInsets.zero, - children: [ - DrawerHeader( - decoration: const BoxDecoration(color: Colors.indigo), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: const [ - Text("販売アシスト1号", style: TextStyle(color: Colors.white, fontSize: 20)), - SizedBox(height: 8), - Text("メニュー", style: TextStyle(color: Colors.white70)), - ], + child: SafeArea( + child: ListView( + padding: EdgeInsets.zero, + children: [ + DrawerHeader( + decoration: const BoxDecoration(color: Colors.indigo), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text("販売アシスト1号", style: TextStyle(color: Colors.white, fontSize: 20)), + SizedBox(height: 8), + Text("クイックメニュー", style: TextStyle(color: Colors.white70)), + ], + ), ), - ), - ListTile( - leading: const Icon(Icons.receipt_long), - title: const Text("伝票マスター"), - onTap: () { - Navigator.pop(context); - }, - ), - ListTile( - leading: const Icon(Icons.people), - title: const Text("顧客マスター"), - onTap: () { - Navigator.pop(context); - Navigator.push(context, MaterialPageRoute(builder: (_) => const CustomerMasterScreen())); - }, - ), - ListTile( - leading: const Icon(Icons.inventory_2), - title: const Text("商品マスター"), - onTap: () { - Navigator.pop(context); - Navigator.push(context, MaterialPageRoute(builder: (_) => const ProductMasterScreen())); - }, - ), - ListTile( - leading: const Icon(Icons.settings), - title: const Text("設定"), - onTap: () { - Navigator.pop(context); - Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsScreen())); - }, - ), - const Divider(), - ListTile( - leading: const Icon(Icons.admin_panel_settings), - title: const Text("管理メニュー"), - onTap: () { - Navigator.pop(context); - Navigator.push(context, MaterialPageRoute(builder: (_) => const ManagementScreen())); - }, - ), - ], + _drawerHeading("アクション"), + ListTile( + leading: const Icon(Icons.add_circle_outline, color: Colors.indigo), + title: const Text("新しい伝票を作成"), + subtitle: const Text("ドキュメント種別を選択"), + onTap: () { + Navigator.pop(context); + _showCreateTypeMenu(); + }, + ), + _drawerHeading("マスター"), + ListTile( + leading: const Icon(Icons.receipt_long), + title: const Text("伝票マスター"), + onTap: () => Navigator.pop(context), + ), + ListTile( + leading: const Icon(Icons.people), + title: const Text("顧客マスター"), + onTap: () { + Navigator.pop(context); + Navigator.push(context, MaterialPageRoute(builder: (_) => const CustomerMasterScreen())); + }, + ), + ListTile( + leading: const Icon(Icons.inventory_2), + title: const Text("商品マスター"), + onTap: () { + Navigator.pop(context); + Navigator.push(context, MaterialPageRoute(builder: (_) => const ProductMasterScreen())); + }, + ), + _drawerHeading("システム"), + ListTile( + leading: const Icon(Icons.settings), + title: const Text("設定"), + onTap: () { + Navigator.pop(context); + Navigator.push(context, MaterialPageRoute(builder: (_) => const SettingsScreen())); + }, + ), + ListTile( + leading: const Icon(Icons.admin_panel_settings), + title: const Text("管理メニュー"), + onTap: () { + Navigator.pop(context); + Navigator.push(context, MaterialPageRoute(builder: (_) => const ManagementScreen())); + }, + ), + ], + ), ), ), appBar: AppBar( @@ -386,11 +397,12 @@ class _InvoiceHistoryScreenState extends State { children: [ if (!_useDashboardHome) Padding( - padding: const EdgeInsets.all(16.0), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), child: SlideToUnlock( isLocked: !_isUnlocked, + lockedText: "A2をロック解除", + unlockedText: "解除済", onUnlocked: _toggleUnlock, - text: "スライドでロック解除", ), ), Expanded( @@ -436,7 +448,7 @@ class _InvoiceHistoryScreenState extends State { onPressed: _isUnlocked ? () => _showCreateTypeMenu() : _requireUnlock, - label: const Text("新規伝票作成"), + label: const Text("新しい伝票"), icon: const Icon(Icons.add), backgroundColor: Colors.indigo, foregroundColor: Colors.white, @@ -444,6 +456,13 @@ class _InvoiceHistoryScreenState extends State { ); } + Widget _drawerHeading(String label) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text(label, style: const TextStyle(fontSize: 12, color: Colors.grey, letterSpacing: 0.5)), + ); + } + void _showCreateTypeMenu() { showModalBottomSheet( context: context, diff --git a/lib/services/mothership_client.dart b/lib/services/mothership_client.dart index 76f3c9d..a2bb040 100644 --- a/lib/services/mothership_client.dart +++ b/lib/services/mothership_client.dart @@ -29,13 +29,14 @@ class MothershipClient { } final clientId = await ensureClientId(); final remaining = expiryInfo.remaining?.inSeconds; + final payload = {'clientId': clientId}; + if (remaining != null) { + payload['remainingLifespanSeconds'] = remaining; + } await _postJson( uri: config.heartbeatUri, apiKey: config.apiKey, - payload: { - 'clientId': clientId, - if (remaining != null) 'remainingLifespanSeconds': remaining, - }, + payload: payload, logLabel: 'heartbeat', ); } diff --git a/lib/widgets/slide_to_unlock.dart b/lib/widgets/slide_to_unlock.dart index fa07a5d..b578e78 100644 --- a/lib/widgets/slide_to_unlock.dart +++ b/lib/widgets/slide_to_unlock.dart @@ -2,14 +2,28 @@ import 'package:flutter/material.dart'; class SlideToUnlock extends StatefulWidget { final VoidCallback onUnlocked; - final String text; + final String lockedText; + final String unlockedText; + final IconData lockedIcon; + final IconData unlockedIcon; final bool isLocked; + final double? height; + final double? thumbSize; + final Color? backgroundColor; + final Color? accentColor; const SlideToUnlock({ super.key, required this.onUnlocked, - this.text = "スライドして解除", + this.lockedText = "スライドして解除", + this.unlockedText = "UNLOCKED", + this.lockedIcon = Icons.lock, + this.unlockedIcon = Icons.check_circle, this.isLocked = true, + this.height = 72, + this.thumbSize = 52, + this.backgroundColor, + this.accentColor, }); @override @@ -18,7 +32,8 @@ class SlideToUnlock extends StatefulWidget { class _SlideToUnlockState extends State { double _position = 0.0; - final double _thumbSize = 56.0; + static const double _trackPadding = 14.0; + bool _showSuccessOverlay = false; @override Widget build(BuildContext context) { @@ -27,13 +42,20 @@ class _SlideToUnlockState extends State { return LayoutBuilder( builder: (context, constraints) { final double maxWidth = constraints.maxWidth; - final double trackWidth = (maxWidth - _thumbSize - 12).clamp(0, maxWidth); + final double thumbSize = widget.thumbSize ?? 52; + final double trackWidth = (maxWidth - thumbSize - (_trackPadding * 2)).clamp(0, maxWidth); + final double progressRatio = trackWidth == 0 ? 0 : (_position / trackWidth).clamp(0, 1); + final double innerWidth = thumbSize + trackWidth; + final double progressWidth = (innerWidth * progressRatio + thumbSize * (1 - progressRatio)).clamp(thumbSize, innerWidth); + final Color background = widget.backgroundColor ?? Colors.blueGrey.shade900; + final Color accentStart = (widget.accentColor ?? Colors.indigo.shade600).withValues(alpha: 0.9); + final Color accentEnd = (widget.accentColor ?? Colors.indigo.shade600); return Container( - height: 64, + height: widget.height ?? 72, margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( - color: Colors.blueGrey.shade900, + color: background, borderRadius: BorderRadius.circular(32), boxShadow: [ BoxShadow(color: Colors.black26, blurRadius: 8, offset: const Offset(0, 4)), @@ -41,27 +63,63 @@ class _SlideToUnlockState extends State { ), child: Stack( children: [ - // 背景テキストとアニメーション効果(簡易) - Center( - child: Opacity( - opacity: (1 - (_position / trackWidth)).clamp(0.2, 1.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.keyboard_double_arrow_right, color: Colors.white54, size: 20), - const SizedBox(width: 8), - Text( - widget.text, - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, letterSpacing: 1.2), + // 進行バー + Padding( + padding: const EdgeInsets.symmetric(horizontal: _trackPadding, vertical: 12), + child: Align( + alignment: Alignment.centerLeft, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + curve: Curves.easeOut, + width: progressWidth, + height: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(28), + gradient: LinearGradient( + colors: [ + accentStart, + accentEnd, + ], ), - ], + boxShadow: const [ + BoxShadow(color: Colors.black26, blurRadius: 6, offset: Offset(0, 2)), + ], + ), ), ), ), + // 背景テキスト + Center( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: _showSuccessOverlay + ? Row( + mainAxisSize: MainAxisSize.min, + key: const ValueKey('unlocked'), + children: [ + Icon(widget.unlockedIcon, color: Colors.white, size: 24), + const SizedBox(width: 6), + Text(widget.unlockedText, style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + ], + ) + : Row( + key: const ValueKey('locked'), + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(widget.lockedIcon, color: Colors.white.withValues(alpha: 0.85), size: 20), + const SizedBox(width: 8), + Text( + widget.lockedText, + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, letterSpacing: 1.1), + ), + ], + ), + ), + ), // スライドつまみ Positioned( - left: _position + 4, - top: 4, + left: _trackPadding + _position, + top: ((widget.height ?? 72) - (widget.thumbSize ?? 52)) / 2, child: GestureDetector( onHorizontalDragUpdate: (details) { setState(() { @@ -72,31 +130,34 @@ class _SlideToUnlockState extends State { }, onHorizontalDragEnd: (details) { if (_position >= trackWidth * 0.65) { + setState(() { + _position = trackWidth; + _showSuccessOverlay = true; + }); widget.onUnlocked(); - // 成功時はアニメーションで戻すのではなく、状態が変わるのでリセット - setState(() => _position = 0); + Future.delayed(const Duration(milliseconds: 450), () { + if (!mounted) return; + setState(() { + _position = 0; + _showSuccessOverlay = false; + }); + }); } else { // 失敗時はバネのように戻る(簡易) setState(() => _position = 0); } }, child: Container( - width: _thumbSize, - height: 56, + width: thumbSize, + height: widget.thumbSize ?? 52, decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [Colors.orangeAccent, Colors.deepOrange], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: BorderRadius.circular(28), - boxShadow: [ - BoxShadow(color: Colors.black45, blurRadius: 4, offset: const Offset(2, 2)), + color: Colors.white, + borderRadius: BorderRadius.circular((widget.thumbSize ?? 52) / 2), + boxShadow: const [ + BoxShadow(color: Colors.black38, blurRadius: 8, offset: Offset(0, 4)), ], ), - child: const Center( - child: Icon(Icons.key, color: Colors.white, size: 24), - ), + child: Icon(Icons.arrow_forward_ios, color: accentEnd, size: 20), ), ), ), diff --git a/pubspec.lock b/pubspec.lock index ff39e83..347a328 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -313,7 +313,7 @@ packages: source: hosted version: "1.0.1" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" diff --git a/test/widget_test.dart b/test/widget_test.dart index 97c35b7..a58d2e9 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -9,11 +9,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:h_1/main.dart'; +import 'package:h_1/utils/build_expiry_info.dart'; void main() { testWidgets('Counter increments smoke test', (WidgetTester tester) async { // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); + final expiryInfo = BuildExpiryInfo.fromEnvironment(); + await tester.pumpWidget(MyApp(expiryInfo: expiryInfo)); // Verify that our counter starts at 0. expect(find.text('0'), findsOneWidget);