Update lot features

This commit is contained in:
2026-05-28 16:29:23 +07:00
parent 6272ece15d
commit 1110e5a42d
81 changed files with 4306 additions and 29813 deletions
@@ -45,6 +45,8 @@ final GoRouter appRouter = GoRouter(
redirect: (context, state) async {
final path = state.matchedLocation;
final serverUrl = await AppConstants.getServerUrl();
final isEditingServer =
path == '/server-connect' && state.uri.queryParameters['edit'] == '1';
final isPublicRoute = path == '/server-connect' ||
path == '/splash' ||
path == '/login' ||
@@ -53,7 +55,8 @@ final GoRouter appRouter = GoRouter(
if ((serverUrl == null || serverUrl.isEmpty) && path != '/server-connect') {
return '/server-connect';
}
if (path == '/server-connect' &&
if (!isEditingServer &&
path == '/server-connect' &&
serverUrl != null &&
serverUrl.isNotEmpty) {
return '/splash';
@@ -88,7 +91,9 @@ final GoRouter appRouter = GoRouter(
routes: [
GoRoute(
path: '/server-connect',
builder: (_, __) => const server_connect.ServerConnectScreen()),
builder: (_, state) => server_connect.ServerConnectScreen(
editMode: state.uri.queryParameters['edit'] == '1',
)),
GoRoute(
path: '/splash', builder: (_, __) => const auth_splash.SplashScreen()),
GoRoute(path: '/login', builder: (_, __) => const auth_login.LoginScreen()),
@@ -106,6 +111,7 @@ final GoRouter appRouter = GoRouter(
callerId: int.tryParse(extra['callerId']?.toString() ?? ''),
channelName: extra['channelName']?.toString(),
agoraToken: extra['agoraToken']?.toString(),
agoraAppId: extra['agoraAppId']?.toString(),
);
}),
ShellRoute(
@@ -1,13 +1,18 @@
import 'package:shared_preferences/shared_preferences.dart';
class AppConstants {
static const String defaultServerUrl = 'http://202.46.28.170:8080';
static const String _serverUrlKey = 'server_base_url';
static const String _selectedYoloModelKey = 'selected_yolo_model';
// Ambil base URL dari SharedPreferences
static Future<String?> getServerUrl() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getString(_serverUrlKey);
final saved = prefs.getString(_serverUrlKey);
if (saved == null || saved.trim().isEmpty) {
return defaultServerUrl;
}
return saved;
}
// Simpan URL setelah berhasil connect
@@ -33,7 +38,7 @@ class AppConstants {
static Future<void> clearServerUrl() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_serverUrlKey);
await prefs.setString(_serverUrlKey, defaultServerUrl);
}
static String buildApiUrl(String baseUrl) => '$baseUrl/api/v1';
@@ -44,14 +44,19 @@ class CallService {
required int receiverId,
required String channelName,
String? agoraToken,
String? agoraAppId,
int receiverUid = 0,
}) async {
await _apiClient.dio.post('/shared/call/notify', data: {
'receiverId': receiverId,
'channelName': channelName,
'agoraToken': agoraToken,
'receiverUid': receiverUid,
});
await _apiClient.dio.post(
'/shared/call/notify',
data: {
'receiverId': receiverId,
'channelName': channelName,
'agoraToken': agoraToken,
'agoraAppId': agoraAppId,
'receiverUid': receiverUid,
},
);
}
Future<Map<String, dynamic>?> startPairedCall({int uid = 0}) async {
@@ -61,12 +66,14 @@ class CallService {
final tokenData = await requestToken(receiverId: receiverId);
final channelName = tokenData?['channelName']?.toString();
final token = tokenData?['token']?.toString();
final appId = tokenData?['appId']?.toString();
final localUid = (tokenData?['uid'] as num?)?.toInt() ?? uid;
if (channelName == null || channelName.isEmpty) return null;
final joined = await joinChannel(
channelName: channelName,
token: token,
appId: appId,
uid: localUid,
);
if (!joined) return null;
@@ -75,6 +82,7 @@ class CallService {
receiverId: receiverId,
channelName: channelName,
agoraToken: token,
agoraAppId: appId,
receiverUid: 0,
);
@@ -94,10 +102,10 @@ class CallService {
required int callerId,
required String channelName,
}) async {
await _apiClient.dio.post('/shared/call/accept', data: {
'callerId': callerId.toString(),
'channelName': channelName,
});
await _apiClient.dio.post(
'/shared/call/accept',
data: {'callerId': callerId.toString(), 'channelName': channelName},
);
}
Future<Map<String, dynamic>?> getAcceptedCall() async {
@@ -126,21 +134,27 @@ class CallService {
Future<void> endCall(int? otherId, {String? channelName}) async {
if (otherId == null) return;
await _apiClient.dio.post('/shared/call/end', data: {
'otherId': otherId.toString(),
if (channelName != null && channelName.isNotEmpty)
'channelName': channelName,
});
await _apiClient.dio.post(
'/shared/call/end',
data: {
'otherId': otherId.toString(),
if (channelName != null && channelName.isNotEmpty)
'channelName': channelName,
},
);
}
Future<bool> joinChannel({
required String channelName,
String? token,
String? appId,
int uid = 0,
}) async {
final joinCompleter = Completer<bool>();
try {
if (AppConstants.agoraAppId.isEmpty) {
final resolvedAppId =
(appId != null && appId.isNotEmpty) ? appId : AppConstants.agoraAppId;
if (resolvedAppId.isEmpty) {
debugPrint('Agora join skipped: AGORA_APP_ID is not configured');
return false;
}
@@ -150,9 +164,7 @@ class CallService {
}
_engine ??= createAgoraRtcEngine();
await _engine!.initialize(
const RtcEngineContext(appId: AppConstants.agoraAppId),
);
await _engine!.initialize(RtcEngineContext(appId: resolvedAppId));
_engine!.registerEventHandler(
RtcEngineEventHandler(
onJoinChannelSuccess: (_, __) {
@@ -183,12 +195,17 @@ class CallService {
await _engine!.setClientRole(role: ClientRoleType.clientRoleBroadcaster);
await _engine!.setAudioProfile(
profile: AudioProfileType.audioProfileDefault,
scenario: AudioScenarioType.audioScenarioMeeting,
scenario: AudioScenarioType.audioScenarioDefault,
);
await _engine!.enableAudio();
await _engine!.enableLocalAudio(true);
await _engine!.muteAllRemoteAudioStreams(false);
await _engine!.muteLocalAudioStream(false);
await _engine!.enableAudioVolumeIndication(
interval: 500,
smooth: 3,
reportVad: true,
);
await _engine!.adjustRecordingSignalVolume(100);
await _engine!.adjustPlaybackSignalVolume(100);
await _engine!.setDefaultAudioRouteToSpeakerphone(true);
@@ -14,7 +14,7 @@ class IncomingCallPollingService {
void start() {
if (_timer != null) return;
_timer = Timer.periodic(const Duration(seconds: 2), (_) => _check());
_timer = Timer.periodic(const Duration(seconds: 1), (_) => _check());
unawaited(_check());
}
@@ -28,7 +28,7 @@ class IncomingCallPollingService {
try {
final res = await _apiClient.dio
.get('/shared/call/pending')
.timeout(const Duration(seconds: 3));
.timeout(const Duration(seconds: 2));
final data = res.data['data'];
if (data is! Map) return;
if (data['type']?.toString() != 'INCOMING_CALL') return;
@@ -42,4 +42,4 @@ class IncomingCallPollingService {
debugPrint('Incoming call polling skipped: $e');
}
}
}
}
@@ -3,9 +3,15 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../app/router.dart';
import '../../app/injection_container.dart';
import '../../core/constants/app_constants.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/services/fcm_service.dart';
import '../../core/services/incoming_call_polling_service.dart';
import '../../core/services/offline_queue_service.dart';
import '../../core/services/websocket_service.dart';
import '../../core/storage/secure_storage.dart';
// ---------------------------------------------------------------------------
@@ -40,7 +46,9 @@ class _SplashScreenState extends State<SplashScreen>
void initState() {
super.initState();
_animCtrl = AnimationController(
vsync: this, duration: const Duration(milliseconds: 700));
vsync: this,
duration: const Duration(milliseconds: 700),
);
_fadeAnim = CurvedAnimation(parent: _animCtrl, curve: Curves.easeIn);
_animCtrl.forward();
_route();
@@ -59,10 +67,12 @@ class _SplashScreenState extends State<SplashScreen>
await Future.delayed(const Duration(milliseconds: 500));
final storage = sl<SecureStorage>();
final token =
await storage.getAccessToken().timeout(const Duration(seconds: 3));
final role =
await storage.getUserRole().timeout(const Duration(seconds: 3));
final token = await storage.getAccessToken().timeout(
const Duration(seconds: 3),
);
final role = await storage.getUserRole().timeout(
const Duration(seconds: 3),
);
if (!mounted) return;
@@ -71,11 +81,16 @@ class _SplashScreenState extends State<SplashScreen>
return;
}
sl<IncomingCallPollingService>().start();
final serverUrl = await AppConstants.getServerUrl();
if (serverUrl != null && serverUrl.isNotEmpty) {
_startAutoLoginServices(serverUrl);
} else {
sl<IncomingCallPollingService>().start();
}
// Auto-login: arahkan ke home sesuai role.
context.go(role == 'ROLE_GUARDIAN'
? '/guardian/dashboard'
: '/user/walkguide');
context.go(
role == 'ROLE_GUARDIAN' ? '/guardian/dashboard' : '/user/walkguide',
);
},
onError: (_) {},
fallback: 'Sesi belum bisa dipulihkan.',
@@ -120,10 +135,7 @@ class _SplashScreenState extends State<SplashScreen>
const SizedBox(height: 6),
const Text(
'AI-powered navigation for everyone',
style: TextStyle(
color: Colors.white70,
fontSize: 13,
),
style: TextStyle(color: Colors.white70, fontSize: 13),
),
const SizedBox(height: 48),
const SizedBox(
@@ -141,3 +153,23 @@ class _SplashScreenState extends State<SplashScreen>
);
}
}
void _startAutoLoginServices(String serverUrl) {
Future.microtask(() async {
sl<IncomingCallPollingService>().start();
await sl<ApiClient>().init(serverUrl);
await sl<FcmService>().init().timeout(const Duration(seconds: 4));
final ws = sl<WebSocketService>();
await ws.connect(serverUrl).timeout(const Duration(seconds: 2));
ws.subscribeCall((data) {
if (data['type']?.toString() == 'INCOMING_CALL') {
appRouter.go('/incoming-call', extra: data);
}
});
await sl<OfflineQueueService>()
.syncPending(sl<ApiClient>())
.timeout(const Duration(seconds: 3));
}).catchError((Object e) {
debugPrint('Auto-login services skipped: $e');
});
}
@@ -47,8 +47,10 @@ class _CallScreenState extends State<CallScreen>
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat(reverse: true);
late final Animation<double> _pulseScale = Tween(begin: 0.95, end: 1.08)
.animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
late final Animation<double> _pulseScale = Tween(
begin: 0.95,
end: 1.08,
).animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
@override
void initState() {
@@ -85,7 +87,7 @@ class _CallScreenState extends State<CallScreen>
);
_startAcceptedPolling();
_ringTimeout?.cancel();
_ringTimeout = Timer(const Duration(seconds: 45), () {
_ringTimeout = Timer(const Duration(seconds: 35), () {
if (!mounted || _phase == _CallPhase.connected) return;
_failCall('Panggilan tidak dijawab.');
});
@@ -93,12 +95,12 @@ class _CallScreenState extends State<CallScreen>
void _startAcceptedPolling() {
_acceptedPoll?.cancel();
_acceptedPoll = Timer.periodic(const Duration(seconds: 2), (_) async {
_acceptedPoll = Timer.periodic(const Duration(seconds: 1), (_) async {
if (!mounted || _activeChannel == null) return;
final state = await runFriendly<Map<String, dynamic>>(
() => sl<CallService>()
.getCallState(_activeChannel)
.timeout(const Duration(seconds: 3)),
.timeout(const Duration(seconds: 2)),
onError: (_) {},
fallback: 'Polling panggilan gagal.',
);
@@ -114,9 +116,9 @@ class _CallScreenState extends State<CallScreen>
}
final accepted = await runFriendly<Map<String, dynamic>>(
() => sl<CallService>()
.getAcceptedCall()
.timeout(const Duration(seconds: 3)),
() => sl<CallService>().getAcceptedCall().timeout(
const Duration(seconds: 2),
),
onError: (_) {},
fallback: 'Polling panggilan diterima gagal.',
);
@@ -283,6 +285,7 @@ class IncomingCallScreen extends StatefulWidget {
final int? callerId;
final String? channelName;
final String? agoraToken;
final String? agoraAppId;
const IncomingCallScreen({
super.key,
@@ -290,6 +293,7 @@ class IncomingCallScreen extends StatefulWidget {
this.callerId,
this.channelName,
this.agoraToken,
this.agoraAppId,
});
@override
@@ -360,12 +364,12 @@ class _IncomingCallScreenState extends State<IncomingCallScreen> {
void _startIncomingStatePolling() {
_statePoll?.cancel();
_statePoll = Timer.periodic(const Duration(seconds: 2), (_) async {
_statePoll = Timer.periodic(const Duration(seconds: 1), (_) async {
if (!mounted || _joinedChannel == null) return;
final state = await runFriendly<Map<String, dynamic>>(
() => sl<CallService>()
.getCallState(_joinedChannel)
.timeout(const Duration(seconds: 3)),
.timeout(const Duration(seconds: 2)),
onError: (_) {},
fallback: 'Polling panggilan masuk gagal.',
);
@@ -389,8 +393,10 @@ class _IncomingCallScreenState extends State<IncomingCallScreen> {
setState(() => _responding = true);
sl<TtsService>().speak('Panggilan ditolak.');
sl<CallService>().setRemoteUserOfflineCallback(null);
await sl<CallService>()
.endCall(widget.callerId, channelName: widget.channelName);
await sl<CallService>().endCall(
widget.callerId,
channelName: widget.channelName,
);
await sl<CallService>().clearPendingCall();
await sl<CallService>().leave();
if (mounted) context.go(await _homeRoute());
@@ -401,16 +407,19 @@ class _IncomingCallScreenState extends State<IncomingCallScreen> {
unawaited(_finishIncomingRemoteEnded());
});
if (widget.callerId != null) {
final tokenData =
await sl<CallService>().requestToken(receiverId: widget.callerId!);
final tokenData = await sl<CallService>().requestToken(
receiverId: widget.callerId!,
);
final channelName = tokenData?['channelName']?.toString();
final token = tokenData?['token']?.toString();
final appId = tokenData?['appId']?.toString();
final uid = (tokenData?['uid'] as num?)?.toInt() ?? 0;
if (channelName != null && channelName.isNotEmpty) {
_joinedChannel = channelName;
return sl<CallService>().joinChannel(
channelName: channelName,
token: token,
appId: appId,
uid: uid,
);
}
@@ -422,6 +431,7 @@ class _IncomingCallScreenState extends State<IncomingCallScreen> {
return sl<CallService>().joinChannel(
channelName: fallbackChannel,
token: widget.agoraToken,
appId: widget.agoraAppId,
);
}
@@ -429,8 +439,10 @@ class _IncomingCallScreenState extends State<IncomingCallScreen> {
_callTimer?.cancel();
_statePoll?.cancel();
sl<CallService>().setRemoteUserOfflineCallback(null);
await sl<CallService>()
.endCall(widget.callerId, channelName: _joinedChannel);
await sl<CallService>().endCall(
widget.callerId,
channelName: _joinedChannel,
);
await sl<CallService>().leave();
sl<TtsService>().speak('Panggilan diakhiri.');
if (mounted) context.go(await _homeRoute());
@@ -713,8 +725,10 @@ class _EndCallButton extends StatelessWidget {
Container(
width: 74,
height: 74,
decoration:
const BoxDecoration(shape: BoxShape.circle, color: _kRed),
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: _kRed,
),
child: const Icon(Icons.call_end, color: Colors.white, size: 32),
),
const SizedBox(height: 6),
@@ -158,8 +158,10 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
context: context,
builder: (dialogContext) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: Text(_labelFromKey(item['commandKey']?.toString() ?? '') ??
'Voice Command'),
title: Text(
_labelFromKey(item['commandKey']?.toString() ?? '') ??
'Voice Command',
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -180,11 +182,13 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel')),
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Save')),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Save'),
),
],
),
),
@@ -209,8 +213,9 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
context: context,
builder: (dialogContext) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: Text(_labelFromKey(item['shortcutKey']?.toString() ?? '') ??
'Shortcut'),
title: Text(
_labelFromKey(item['shortcutKey']?.toString() ?? '') ?? 'Shortcut',
),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -230,6 +235,31 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
prefixIcon: Icon(Icons.numbers_outlined),
),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
ActionChip(
avatar: const Icon(Icons.volume_up_outlined, size: 18),
label: const Text('Volume Up'),
onPressed: () {
buttonName.text = 'Volume Up';
buttonCode.text = '24';
setDialogState(() {});
},
),
ActionChip(
avatar: const Icon(Icons.volume_down_outlined, size: 18),
label: const Text('Volume Down'),
onPressed: () {
buttonName.text = 'Volume Down';
buttonCode.text = '25';
setDialogState(() {});
},
),
],
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: enabled,
@@ -240,11 +270,13 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel')),
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Save')),
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('Save'),
),
],
),
),
@@ -273,8 +305,9 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
},
onError: (message) {
if (!mounted) return;
ScaffoldMessenger.of(context)
.showSnackBar(SnackBar(content: Text(message)));
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
},
fallback: 'Konfigurasi belum bisa disimpan.',
);
@@ -301,18 +334,15 @@ class _EndpointCard extends StatelessWidget {
'',
) ??
'Item #${item['id'] ?? '-'}';
final subtitle = _firstText(
item,
[
'triggerPhrase',
'buttonName',
'description',
'action',
'shortcut',
'status',
'createdAt'
],
) ??
final subtitle = _firstText(item, [
'triggerPhrase',
'buttonName',
'description',
'action',
'shortcut',
'status',
'createdAt',
]) ??
'Data aktif';
final enabled = item['enabled'] != false;
return Container(
@@ -338,11 +368,15 @@ class _EndpointCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: const TextStyle(fontWeight: FontWeight.w800)),
Text(
title,
style: const TextStyle(fontWeight: FontWeight.w800),
),
const SizedBox(height: 3),
Text(subtitle,
style: const TextStyle(color: Color(0xFF64748B))),
Text(
subtitle,
style: const TextStyle(color: Color(0xFF64748B)),
),
const SizedBox(height: 6),
Wrap(
spacing: 8,
@@ -415,7 +449,6 @@ String? _labelFromKey(String value) {
return value
.split('_')
.where((part) => part.isNotEmpty)
.map((part) =>
part[0].toUpperCase() + part.substring(1).toLowerCase())
.map((part) => part[0].toUpperCase() + part.substring(1).toLowerCase())
.join(' ');
}
@@ -10,24 +10,37 @@ import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
class ServerConnectScreen extends StatefulWidget {
const ServerConnectScreen({super.key});
final bool editMode;
const ServerConnectScreen({super.key, this.editMode = false});
@override
State<ServerConnectScreen> createState() => _ServerConnectScreenState();
}
class _ServerConnectScreenState extends State<ServerConnectScreen> {
final _url = TextEditingController(text: 'http://127.0.0.1:8080');
final _url = TextEditingController(text: AppConstants.defaultServerUrl);
bool _loading = false;
bool _ok = false;
String? _message;
@override
void initState() {
super.initState();
_loadCurrentUrl();
}
@override
void dispose() {
_url.dispose();
super.dispose();
}
Future<void> _loadCurrentUrl() async {
final current = await AppConstants.getServerUrl();
if (!mounted || current == null || current.isEmpty) return;
setState(() => _url.text = current);
}
Future<void> _test() async {
setState(() {
_loading = true;
@@ -38,10 +51,12 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
() async {
final clean = AppConstants.normalizeServerUrl(_url.text);
await sl<ApiClient>().init(clean);
final res = await Dio(BaseOptions(
connectTimeout: AppConstants.pingTimeout,
receiveTimeout: AppConstants.pingTimeout,
)).get('$clean/api/v1/auth/ping');
final res = await Dio(
BaseOptions(
connectTimeout: AppConstants.pingTimeout,
receiveTimeout: AppConstants.pingTimeout,
),
).get('$clean/api/v1/auth/ping');
_ok = res.statusCode == 200 && res.data['success'] == true;
_message = _ok
? 'Server aktif. WalkGuide siap tersambung.'
@@ -49,7 +64,7 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
},
onError: (message) => _message = message,
fallback:
'Tidak bisa terhubung. Untuk HP via USB, jalankan adb reverse tcp:8080 tcp:8080 lalu pakai http://127.0.0.1:8080.',
'Tidak bisa terhubung ke server publik. Pastikan backend aktif dan jaringan HP stabil.',
);
if (mounted) setState(() => _loading = false);
}
@@ -58,17 +73,16 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
final clean = AppConstants.normalizeServerUrl(_url.text);
await AppConstants.setServerUrl(clean);
await sl<ApiClient>().init(clean);
if (mounted) context.go('/splash');
if (mounted) context.go(widget.editMode ? '/login' : '/splash');
}
void _useUsbUrl() => setState(() => _url.text = 'http://127.0.0.1:8080');
void _useAndroidEmulatorUrl() =>
setState(() => _url.text = 'http://10.0.2.2:8080');
void _usePublicUrl() =>
setState(() => _url.text = AppConstants.defaultServerUrl);
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF5F8FC),
backgroundColor: const Color(0xFF061421),
body: Stack(
children: [
const Positioned.fill(
@@ -79,24 +93,13 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
end: Alignment.bottomRight,
colors: [
Color(0xFF071226),
Color(0xFF123D6B),
Color(0xFFF7FAFC)
Color(0xFF0F3B57),
Color(0xFF0B6B6C),
],
stops: [0, 0.42, 1],
),
),
),
),
const Positioned(
top: -80,
right: -70,
child: _GlowBlob(size: 250, color: Color(0xFF38BDF8)),
),
const Positioned(
bottom: -90,
left: -80,
child: _GlowBlob(size: 260, color: Color(0xFF22C55E)),
),
SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
@@ -109,190 +112,220 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
ScrollViewKeyboardDismissBehavior.onDrag,
padding: EdgeInsets.fromLTRB(
horizontalPadding,
compact ? 10 : 32,
compact ? 12 : 28,
horizontalPadding,
20 + MediaQuery.of(context).viewInsets.bottom,
12 + MediaQuery.of(context).viewInsets.bottom,
),
child: Center(
child: ConstrainedBox(
constraints:
BoxConstraints(maxWidth: compact ? 380 : 520),
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 18, end: 0),
duration: const Duration(milliseconds: 520),
curve: Curves.easeOutCubic,
builder: (_, y, child) => Opacity(
opacity: (1 - y / 18).clamp(0, 1),
child: Transform.translate(
offset: Offset(0, y), child: child),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight -
MediaQuery.of(context).padding.vertical -
24,
),
child: Center(
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: compact ? 420 : 520,
),
child: Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.96),
borderRadius:
BorderRadius.circular(compact ? 22 : 28),
border: Border.all(
color: Colors.white.withValues(alpha: 0.7)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.18),
blurRadius: 34,
offset: const Offset(0, 22),
),
],
child: TweenAnimationBuilder<double>(
tween: Tween(begin: 18, end: 0),
duration: const Duration(milliseconds: 520),
curve: Curves.easeOutCubic,
builder: (_, y, child) => Opacity(
opacity: (1 - y / 18).clamp(0, 1),
child: Transform.translate(
offset: Offset(0, y),
child: child,
),
),
child: ClipRRect(
borderRadius:
BorderRadius.circular(compact ? 22 : 28),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: EdgeInsets.fromLTRB(
compact ? 14 : 22,
compact ? 14 : 22,
compact ? 14 : 22,
compact ? 14 : 20,
),
decoration: const BoxDecoration(
color: Color(0xFF071226),
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: compact ? 38 : 48,
height: compact ? 38 : 48,
decoration: BoxDecoration(
color: const Color(0xFF2563EB),
borderRadius:
BorderRadius.circular(16),
child: Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.96),
borderRadius: BorderRadius.circular(
compact ? 22 : 28,
),
border: Border.all(
color: Colors.white.withValues(alpha: 0.7),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.18),
blurRadius: 34,
offset: const Offset(0, 22),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(
compact ? 22 : 28,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Container(
padding: EdgeInsets.fromLTRB(
compact ? 14 : 22,
compact ? 14 : 22,
compact ? 14 : 22,
compact ? 14 : 20,
),
decoration: const BoxDecoration(
color: Color(0xFF071226),
),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: compact ? 38 : 48,
height: compact ? 38 : 48,
decoration: BoxDecoration(
color: const Color(0xFF2563EB),
borderRadius:
BorderRadius.circular(16),
),
child: Icon(
Icons.navigation_rounded,
color: Colors.white,
size: compact ? 24 : 28,
),
),
child: Icon(
Icons.navigation_rounded,
color: Colors.white,
size: compact ? 24 : 28,
const SizedBox(width: 12),
Expanded(
child: Text(
'WalkGuide',
style: TextStyle(
color: Colors.white,
fontSize: compact ? 16 : 20,
fontWeight: FontWeight.w900,
),
),
),
],
),
SizedBox(height: compact ? 14 : 18),
Text(
'Connect to Server',
style: TextStyle(
color: Colors.white,
fontSize: compact ? 22 : 30,
fontWeight: FontWeight.w900,
height: 1,
),
),
const SizedBox(height: 8),
Text(
widget.editMode
? 'Ubah alamat backend Spring Boot WalkGuide yang aktif.'
: 'Sambungkan app HP ke backend Spring Boot publik WalkGuide.',
style: TextStyle(
color: Colors.white.withValues(
alpha: 0.72,
),
height: 1.35,
),
),
],
),
),
Padding(
padding: EdgeInsets.all(compact ? 14 : 22),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
TextField(
controller: _url,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _test(),
decoration: const InputDecoration(
labelText: 'Server URL',
hintText:
AppConstants.defaultServerUrl,
prefixIcon: Icon(
Icons.dns_outlined,
),
),
const SizedBox(width: 12),
Expanded(
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_HintChip(
icon: Icons.public_outlined,
label: 'Server: 202.46.28.170',
onTap: _usePublicUrl,
),
],
),
SizedBox(height: compact ? 12 : 16),
OutlinedButton.icon(
onPressed: _loading ? null : _test,
icon: _loading
? const SizedBox(
width: 18,
height: 18,
child:
CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(
Icons.radar_outlined,
),
label: const Text('Test Connection'),
),
if (_message != null) ...[
const SizedBox(height: 12),
_StatusBox(
success: _ok,
message: _message!,
),
],
if (_ok) ...[
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _continue,
icon: const Icon(
Icons.arrow_forward_rounded,
),
label: Text(widget.editMode
? 'Simpan Server'
: 'Continue'),
),
],
if (widget.editMode) ...[
const SizedBox(height: 8),
TextButton.icon(
onPressed: () =>
context.go('/login'),
icon: const Icon(
Icons.arrow_back_rounded,
),
label: const Text('Kembali Login'),
),
],
if (!compact) ...[
const SizedBox(height: 18),
const Center(
child: Text(
'WalkGuide',
'v1.0.0 | Spring Boot + Flutter',
style: TextStyle(
color: Colors.white,
fontSize: compact ? 16 : 20,
fontWeight: FontWeight.w900,
fontSize: 11,
color: Color(0xFF94A3B8),
),
),
),
],
),
SizedBox(height: compact ? 14 : 18),
Text(
'Connect to Server',
style: TextStyle(
color: Colors.white,
fontSize: compact ? 22 : 30,
fontWeight: FontWeight.w900,
height: 1,
),
),
const SizedBox(height: 8),
Text(
'Sambungkan app HP ke backend Spring Boot yang sedang berjalan di laptop.',
style: TextStyle(
color: Colors.white
.withValues(alpha: 0.72),
height: 1.35,
),
),
],
],
),
),
),
Padding(
padding: EdgeInsets.all(compact ? 14 : 22),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
TextField(
controller: _url,
keyboardType: TextInputType.url,
textInputAction: TextInputAction.done,
onSubmitted: (_) => _test(),
decoration: const InputDecoration(
labelText: 'Server URL',
hintText: 'http://127.0.0.1:8080',
prefixIcon: Icon(Icons.dns_outlined),
),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_HintChip(
icon: Icons.usb_outlined,
label: 'USB: 127.0.0.1',
onTap: _useUsbUrl,
),
_HintChip(
icon: Icons.phone_android_outlined,
label: 'Emulator: 10.0.2.2',
onTap: _useAndroidEmulatorUrl,
),
const _HintChip(
icon: Icons.wifi_tethering_outlined,
label: 'Wi-Fi: IP laptop',
),
],
),
SizedBox(height: compact ? 12 : 16),
OutlinedButton.icon(
onPressed: _loading ? null : _test,
icon: _loading
? const SizedBox(
width: 18,
height: 18,
child:
CircularProgressIndicator(
strokeWidth: 2),
)
: const Icon(Icons.radar_outlined),
label: const Text('Test Connection'),
),
if (_message != null) ...[
const SizedBox(height: 12),
_StatusBox(
success: _ok, message: _message!),
],
if (_ok) ...[
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _continue,
icon: const Icon(
Icons.arrow_forward_rounded),
label: const Text('Continue'),
),
],
if (!compact) ...[
const SizedBox(height: 18),
const Center(
child: Text(
'v1.0.0 | Spring Boot + Flutter',
style: TextStyle(
fontSize: 11,
color: Color(0xFF94A3B8)),
),
),
],
],
),
),
],
],
),
),
),
),
@@ -309,30 +342,6 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
}
}
class _GlowBlob extends StatelessWidget {
final double size;
final Color color;
const _GlowBlob({required this.size, required this.color});
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color.withValues(alpha: 0.18),
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 0.22),
blurRadius: 60,
spreadRadius: 8),
],
),
);
}
}
class _HintChip extends StatelessWidget {
final IconData icon;
final String label;
@@ -388,12 +397,18 @@ class _StatusBox extends StatelessWidget {
),
child: Row(
children: [
Icon(success ? Icons.check_circle_outline : Icons.error_outline,
color: color, size: 20),
Icon(
success ? Icons.check_circle_outline : Icons.error_outline,
color: color,
size: 20,
),
const SizedBox(width: 8),
Expanded(
child: Text(message,
style: TextStyle(color: color, fontWeight: FontWeight.w700))),
child: Text(
message,
style: TextStyle(color: color, fontWeight: FontWeight.w700),
),
),
],
),
);