👽 Update the OIDC login
This commit is contained in:
parent
eb4d2c2e2f
commit
7f4e489f51
@ -19,6 +19,7 @@ import 'package:island/pods/network.dart';
|
|||||||
import 'package:island/pods/userinfo.dart';
|
import 'package:island/pods/userinfo.dart';
|
||||||
import 'package:island/pods/websocket.dart';
|
import 'package:island/pods/websocket.dart';
|
||||||
import 'package:island/screens/account/me/settings_connections.dart';
|
import 'package:island/screens/account/me/settings_connections.dart';
|
||||||
|
import 'package:island/screens/auth/oidc.dart';
|
||||||
import 'package:island/services/notify.dart';
|
import 'package:island/services/notify.dart';
|
||||||
import 'package:island/services/udid.dart';
|
import 'package:island/services/udid.dart';
|
||||||
import 'package:island/widgets/alert.dart';
|
import 'package:island/widgets/alert.dart';
|
||||||
@ -174,6 +175,67 @@ class _LoginCheckScreen extends HookConsumerWidget {
|
|||||||
return null;
|
return null;
|
||||||
}, [isBusy]);
|
}, [isBusy]);
|
||||||
|
|
||||||
|
Future<void> getToken({String? code}) async {
|
||||||
|
// Get token if challenge is completed
|
||||||
|
final client = ref.watch(apiClientProvider);
|
||||||
|
final tokenResp = await client.post(
|
||||||
|
'/auth/token',
|
||||||
|
data: {
|
||||||
|
'grant_type': 'authorization_code',
|
||||||
|
'code': code ?? challenge!.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final token = tokenResp.data['token'];
|
||||||
|
setToken(ref.watch(sharedPreferencesProvider), token);
|
||||||
|
ref.invalidate(tokenProvider);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
// Do post login tasks
|
||||||
|
final userNotifier = ref.read(userInfoProvider.notifier);
|
||||||
|
userNotifier.fetchUser().then((_) {
|
||||||
|
final apiClient = ref.read(apiClientProvider);
|
||||||
|
subscribePushNotification(apiClient);
|
||||||
|
final wsNotifier = ref.read(websocketStateProvider.notifier);
|
||||||
|
wsNotifier.connect();
|
||||||
|
if (context.mounted) Navigator.pop(context, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update the sessions' device name is available
|
||||||
|
if (!kIsWeb) {
|
||||||
|
String? name;
|
||||||
|
if (Platform.isIOS) {
|
||||||
|
final deviceInfo = await DeviceInfoPlugin().iosInfo;
|
||||||
|
name = deviceInfo.name;
|
||||||
|
} else if (Platform.isAndroid) {
|
||||||
|
final deviceInfo = await DeviceInfoPlugin().androidInfo;
|
||||||
|
name = deviceInfo.name;
|
||||||
|
} else if (Platform.isWindows) {
|
||||||
|
final deviceInfo = await DeviceInfoPlugin().windowsInfo;
|
||||||
|
name = deviceInfo.computerName;
|
||||||
|
}
|
||||||
|
if (name != null) {
|
||||||
|
final client = ref.watch(apiClientProvider);
|
||||||
|
await client.patch(
|
||||||
|
'/accounts/me/sessions/current/label',
|
||||||
|
data: jsonEncode(name),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
if (challenge != null && challenge?.stepRemain == 0) {
|
||||||
|
Future(() {
|
||||||
|
isBusy.value = true;
|
||||||
|
getToken().catchError((err) {
|
||||||
|
showErrorAlert(err);
|
||||||
|
isBusy.value = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [challenge]);
|
||||||
|
|
||||||
Future<void> performCheckTicket() async {
|
Future<void> performCheckTicket() async {
|
||||||
final pwd = passwordController.value.text;
|
final pwd = passwordController.value.text;
|
||||||
if (pwd.isEmpty) return;
|
if (pwd.isEmpty) return;
|
||||||
@ -192,47 +254,7 @@ class _LoginCheckScreen extends HookConsumerWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get token if challenge is completed
|
await getToken(code: result.id);
|
||||||
final tokenResp = await client.post(
|
|
||||||
'/auth/token',
|
|
||||||
data: {'grant_type': 'authorization_code', 'code': result.id},
|
|
||||||
);
|
|
||||||
final token = tokenResp.data['token'];
|
|
||||||
setToken(ref.watch(sharedPreferencesProvider), token);
|
|
||||||
ref.invalidate(tokenProvider);
|
|
||||||
if (!context.mounted) return;
|
|
||||||
|
|
||||||
// Do post login tasks
|
|
||||||
final userNotifier = ref.read(userInfoProvider.notifier);
|
|
||||||
userNotifier.fetchUser().then((_) {
|
|
||||||
final apiClient = ref.read(apiClientProvider);
|
|
||||||
subscribePushNotification(apiClient);
|
|
||||||
final wsNotifier = ref.read(websocketStateProvider.notifier);
|
|
||||||
wsNotifier.connect();
|
|
||||||
if (context.mounted) Navigator.pop(context, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update the sessions' device name is available
|
|
||||||
if (!kIsWeb) {
|
|
||||||
String? name;
|
|
||||||
if (Platform.isIOS) {
|
|
||||||
final deviceInfo = await DeviceInfoPlugin().iosInfo;
|
|
||||||
name = deviceInfo.name;
|
|
||||||
} else if (Platform.isAndroid) {
|
|
||||||
final deviceInfo = await DeviceInfoPlugin().androidInfo;
|
|
||||||
name = deviceInfo.name;
|
|
||||||
} else if (Platform.isWindows) {
|
|
||||||
final deviceInfo = await DeviceInfoPlugin().windowsInfo;
|
|
||||||
name = deviceInfo.computerName;
|
|
||||||
}
|
|
||||||
if (name != null) {
|
|
||||||
final client = ref.watch(apiClientProvider);
|
|
||||||
await client.patch(
|
|
||||||
'/accounts/me/sessions/current/label',
|
|
||||||
data: jsonEncode(name),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showErrorAlert(err);
|
showErrorAlert(err);
|
||||||
return;
|
return;
|
||||||
@ -346,6 +368,14 @@ class _LoginPickerScreen extends HookConsumerWidget {
|
|||||||
return null;
|
return null;
|
||||||
}, [isBusy]);
|
}, [isBusy]);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
if (ticket != null && ticket?.stepRemain == 0) {
|
||||||
|
onPickFactor(factors!.first);
|
||||||
|
onNext();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [ticket]);
|
||||||
|
|
||||||
final unfocusColor = Theme.of(
|
final unfocusColor = Theme.of(
|
||||||
context,
|
context,
|
||||||
).colorScheme.onSurface.withAlpha((255 * 0.75).round());
|
).colorScheme.onSurface.withAlpha((255 * 0.75).round());
|
||||||
@ -569,7 +599,6 @@ class _LoginLookupScreen extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (context.mounted) showLoadingModal(context);
|
if (context.mounted) showLoadingModal(context);
|
||||||
|
|
||||||
final resp = await client.post(
|
final resp = await client.post(
|
||||||
'/auth/login/apple/mobile',
|
'/auth/login/apple/mobile',
|
||||||
data: {
|
data: {
|
||||||
@ -578,20 +607,18 @@ class _LoginLookupScreen extends HookConsumerWidget {
|
|||||||
'device_id': await getUdid(),
|
'device_id': await getUdid(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
final token = resp.data['token'];
|
|
||||||
setToken(ref.watch(sharedPreferencesProvider), token);
|
|
||||||
ref.invalidate(tokenProvider);
|
|
||||||
if (!context.mounted) return;
|
|
||||||
|
|
||||||
// Do post login tasks
|
final challenge = SnAuthChallenge.fromJson(resp.data);
|
||||||
final userNotifier = ref.read(userInfoProvider.notifier);
|
onChallenge(challenge);
|
||||||
userNotifier.fetchUser().then((_) {
|
final factorResp = await client.get(
|
||||||
final apiClient = ref.read(apiClientProvider);
|
'/auth/challenge/${challenge.id}/factors',
|
||||||
subscribePushNotification(apiClient);
|
);
|
||||||
final wsNotifier = ref.read(websocketStateProvider.notifier);
|
onFactor(
|
||||||
wsNotifier.connect();
|
List<SnAuthFactor>.from(
|
||||||
if (context.mounted) Navigator.pop(context, true);
|
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
|
||||||
});
|
),
|
||||||
|
);
|
||||||
|
onNext();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err is SignInWithAppleAuthorizationException) return;
|
if (err is SignInWithAppleAuthorizationException) return;
|
||||||
showErrorAlert(err);
|
showErrorAlert(err);
|
||||||
@ -600,6 +627,32 @@ class _LoginLookupScreen extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> withOidc(String provider) async {
|
||||||
|
final challengeId = await Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => OidcScreen(provider: provider.toLowerCase()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final client = ref.watch(apiClientProvider);
|
||||||
|
try {
|
||||||
|
final resp = await client.get('/auth/challenge/$challengeId');
|
||||||
|
final challenge = SnAuthChallenge.fromJson(resp.data);
|
||||||
|
onChallenge(challenge);
|
||||||
|
final factorResp = await client.get(
|
||||||
|
'/auth/challenge/${challenge.id}/factors',
|
||||||
|
);
|
||||||
|
onFactor(
|
||||||
|
List<SnAuthFactor>.from(
|
||||||
|
factorResp.data.map((ele) => SnAuthFactor.fromJson(ele)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
onNext();
|
||||||
|
} catch (err) {
|
||||||
|
showErrorAlert(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@ -635,6 +688,26 @@ class _LoginLookupScreen extends HookConsumerWidget {
|
|||||||
Text("loginOr").tr().fontSize(11).opacity(0.85),
|
Text("loginOr").tr().fontSize(11).opacity(0.85),
|
||||||
const Gap(8),
|
const Gap(8),
|
||||||
Spacer(),
|
Spacer(),
|
||||||
|
IconButton.filledTonal(
|
||||||
|
onPressed: () => withOidc('github'),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
icon: getProviderIcon(
|
||||||
|
"github",
|
||||||
|
size: 16,
|
||||||
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
tooltip: 'GitHub',
|
||||||
|
),
|
||||||
|
IconButton.filledTonal(
|
||||||
|
onPressed: () => withOidc('google'),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
icon: getProviderIcon(
|
||||||
|
"google",
|
||||||
|
size: 16,
|
||||||
|
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
tooltip: 'Google',
|
||||||
|
),
|
||||||
IconButton.filledTonal(
|
IconButton.filledTonal(
|
||||||
onPressed: withApple,
|
onPressed: withApple,
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
|
@ -9,6 +9,7 @@ import 'package:easy_localization/easy_localization.dart';
|
|||||||
import 'package:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:island/pods/config.dart';
|
import 'package:island/pods/config.dart';
|
||||||
import 'package:island/pods/network.dart';
|
import 'package:island/pods/network.dart';
|
||||||
|
import 'package:island/services/udid.dart';
|
||||||
import 'package:island/widgets/app_scaffold.dart';
|
import 'package:island/widgets/app_scaffold.dart';
|
||||||
import 'package:styled_widget/styled_widget.dart';
|
import 'package:styled_widget/styled_widget.dart';
|
||||||
|
|
||||||
@ -27,6 +28,13 @@ class _OidcScreenState extends ConsumerState<OidcScreen> {
|
|||||||
String? currentUrl;
|
String? currentUrl;
|
||||||
final TextEditingController _urlController = TextEditingController();
|
final TextEditingController _urlController = TextEditingController();
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
late Future<String> _deviceIdFuture;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_deviceIdFuture = getUdid();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@ -43,155 +51,174 @@ class _OidcScreenState extends ConsumerState<OidcScreen> {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
title: widget.title != null ? Text(widget.title!) : Text('login').tr(),
|
title: widget.title != null ? Text(widget.title!) : Text('login').tr(),
|
||||||
),
|
),
|
||||||
body: Column(
|
body: FutureBuilder<String>(
|
||||||
children: [
|
future: _deviceIdFuture,
|
||||||
Expanded(
|
builder: (context, snapshot) {
|
||||||
child: InAppWebView(
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
initialSettings: InAppWebViewSettings(
|
return const Center(child: CircularProgressIndicator());
|
||||||
userAgent:
|
}
|
||||||
kIsWeb
|
|
||||||
? null
|
if (snapshot.hasError) {
|
||||||
: Platform.isIOS
|
return Center(child: Text('somethingWentWrong').tr());
|
||||||
? 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1'
|
}
|
||||||
: Platform.isAndroid
|
|
||||||
? 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36'
|
final deviceId = snapshot.data!;
|
||||||
: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36',
|
|
||||||
),
|
return Column(
|
||||||
initialUrlRequest: URLRequest(
|
children: [
|
||||||
url: WebUri('$serverUrl/auth/login/${widget.provider}'),
|
Expanded(
|
||||||
headers: {
|
child: InAppWebView(
|
||||||
if (token?.token.isNotEmpty ?? false)
|
initialSettings: InAppWebViewSettings(
|
||||||
'Authorization': 'AtField ${token!.token}',
|
userAgent:
|
||||||
},
|
kIsWeb
|
||||||
),
|
? null
|
||||||
onWebViewCreated: (controller) {
|
: Platform.isIOS
|
||||||
// Register a handler to receive the token from JavaScript
|
? 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.0 Mobile/15E148 Safari/604.1'
|
||||||
controller.addJavaScriptHandler(
|
: Platform.isAndroid
|
||||||
handlerName: 'tokenHandler',
|
? 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Mobile Safari/537.36'
|
||||||
callback: (args) {
|
: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36',
|
||||||
// args[0] will be the token string
|
),
|
||||||
if (args.isNotEmpty && args[0] is String) {
|
initialUrlRequest: URLRequest(
|
||||||
|
url: WebUri('$serverUrl/auth/login/${widget.provider}'),
|
||||||
|
headers: {
|
||||||
|
if (token?.token.isNotEmpty ?? false)
|
||||||
|
'Authorization': 'AtField ${token!.token}',
|
||||||
|
'X-Device-Id': deviceId,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
onWebViewCreated: (controller) {
|
||||||
|
// Register a handler to receive the token from JavaScript
|
||||||
|
controller.addJavaScriptHandler(
|
||||||
|
handlerName: 'tokenHandler',
|
||||||
|
callback: (args) {
|
||||||
|
// args[0] will be the token string
|
||||||
|
if (args.isNotEmpty && args[0] is String) {
|
||||||
|
setState(() {
|
||||||
|
authToken = args[0];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Return the token and close the webview
|
||||||
|
Navigator.of(context).pop(authToken);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
shouldOverrideUrlLoading: (
|
||||||
|
controller,
|
||||||
|
navigationAction,
|
||||||
|
) async {
|
||||||
|
final url = navigationAction.request.url;
|
||||||
|
if (url != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
authToken = args[0];
|
currentUrl = url.toString();
|
||||||
|
_urlController.text = currentUrl ?? '';
|
||||||
|
_isLoading = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Return the token and close the webview
|
final path = url.path;
|
||||||
Navigator.of(context).pop(authToken);
|
final queryParams = url.queryParameters;
|
||||||
|
|
||||||
|
// Check if we're on the token page
|
||||||
|
if (path.endsWith('/auth/callback')) {
|
||||||
|
// Extract token from URL
|
||||||
|
final challenge = queryParams['challenge'];
|
||||||
|
// Return the token and close the webview
|
||||||
|
Navigator.of(context).pop(challenge);
|
||||||
|
return NavigationActionPolicy.CANCEL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return NavigationActionPolicy.ALLOW;
|
||||||
|
},
|
||||||
|
onUpdateVisitedHistory: (controller, url, androidIsReload) {
|
||||||
|
if (url != null) {
|
||||||
|
setState(() {
|
||||||
|
currentUrl = url.toString();
|
||||||
|
_urlController.text = currentUrl ?? '';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
onLoadStop: (controller, url) {
|
||||||
},
|
setState(() {
|
||||||
shouldOverrideUrlLoading: (controller, navigationAction) async {
|
_isLoading = false;
|
||||||
final url = navigationAction.request.url;
|
});
|
||||||
if (url != null) {
|
},
|
||||||
setState(() {
|
onLoadStart: (controller, url) {
|
||||||
currentUrl = url.toString();
|
setState(() {
|
||||||
_urlController.text = currentUrl ?? '';
|
_isLoading = true;
|
||||||
_isLoading = true;
|
});
|
||||||
});
|
},
|
||||||
|
onLoadError: (controller, url, code, message) {
|
||||||
final path = url.path;
|
setState(() {
|
||||||
final queryParams = url.queryParameters;
|
_isLoading = false;
|
||||||
|
});
|
||||||
// Check if we're on the token page
|
},
|
||||||
if (path.contains('/auth/callback')) {
|
|
||||||
// Extract token from URL
|
|
||||||
final token = queryParams['token'] ?? true;
|
|
||||||
// Return the token and close the webview
|
|
||||||
Navigator.of(context).pop(token);
|
|
||||||
return NavigationActionPolicy.CANCEL;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return NavigationActionPolicy.ALLOW;
|
|
||||||
},
|
|
||||||
onUpdateVisitedHistory: (controller, url, androidIsReload) {
|
|
||||||
if (url != null) {
|
|
||||||
setState(() {
|
|
||||||
currentUrl = url.toString();
|
|
||||||
_urlController.text = currentUrl ?? '';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onLoadStop: (controller, url) {
|
|
||||||
setState(() {
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onLoadStart: (controller, url) {
|
|
||||||
setState(() {
|
|
||||||
_isLoading = true;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onLoadError: (controller, url, code, message) {
|
|
||||||
setState(() {
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Loading progress indicator
|
|
||||||
if (_isLoading)
|
|
||||||
LinearProgressIndicator(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
|
|
||||||
borderRadius: BorderRadius.zero,
|
|
||||||
stopIndicatorRadius: 0,
|
|
||||||
minHeight: 2,
|
|
||||||
)
|
|
||||||
else
|
|
||||||
ColoredBox(
|
|
||||||
color: Theme.of(context).colorScheme.surfaceVariant,
|
|
||||||
).height(2),
|
|
||||||
// Debug location bar (only visible in debug mode)
|
|
||||||
Container(
|
|
||||||
padding: EdgeInsets.only(
|
|
||||||
left: 16,
|
|
||||||
right: 0,
|
|
||||||
bottom: MediaQuery.of(context).padding.bottom + 8,
|
|
||||||
top: 8,
|
|
||||||
),
|
|
||||||
color: Theme.of(context).colorScheme.surface,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: TextField(
|
|
||||||
controller: _urlController,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
isDense: true,
|
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 8,
|
|
||||||
vertical: 8,
|
|
||||||
),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
hintText: 'URL',
|
|
||||||
),
|
|
||||||
style: const TextStyle(fontSize: 12),
|
|
||||||
readOnly: true,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const Gap(4),
|
),
|
||||||
IconButton(
|
// Loading progress indicator
|
||||||
icon: const Icon(Icons.copy, size: 20),
|
if (_isLoading)
|
||||||
padding: const EdgeInsets.all(4),
|
LinearProgressIndicator(
|
||||||
constraints: const BoxConstraints(),
|
color: Theme.of(context).colorScheme.primary,
|
||||||
onPressed: () {
|
backgroundColor: Theme.of(context).colorScheme.surfaceVariant,
|
||||||
if (currentUrl != null) {
|
borderRadius: BorderRadius.zero,
|
||||||
Clipboard.setData(ClipboardData(text: currentUrl!));
|
stopIndicatorRadius: 0,
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
minHeight: 2,
|
||||||
SnackBar(
|
)
|
||||||
content: Text('copyToClipboard').tr(),
|
else
|
||||||
duration: const Duration(seconds: 1),
|
ColoredBox(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceVariant,
|
||||||
|
).height(2),
|
||||||
|
// Debug location bar (only visible in debug mode)
|
||||||
|
Container(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: 16,
|
||||||
|
right: 0,
|
||||||
|
bottom: MediaQuery.of(context).padding.bottom + 8,
|
||||||
|
top: 8,
|
||||||
|
),
|
||||||
|
color: Theme.of(context).colorScheme.surface,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: TextField(
|
||||||
|
controller: _urlController,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
hintText: 'URL',
|
||||||
),
|
),
|
||||||
);
|
style: const TextStyle(fontSize: 12),
|
||||||
}
|
readOnly: true,
|
||||||
},
|
),
|
||||||
|
),
|
||||||
|
const Gap(4),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.copy, size: 20),
|
||||||
|
padding: const EdgeInsets.all(4),
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
onPressed: () {
|
||||||
|
if (currentUrl != null) {
|
||||||
|
Clipboard.setData(ClipboardData(text: currentUrl!));
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('copyToClipboard').tr(),
|
||||||
|
duration: const Duration(seconds: 1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
);
|
||||||
],
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user