11import 'package:flutter/material.dart' ;
22import 'package:flutter/services.dart' ;
33import 'package:flutter_riverpod/flutter_riverpod.dart' ;
4+ import 'package:go_router/go_router.dart' ;
45
56import '../../data/api/api_client.dart' ;
67import '../../data/providers/auth_provider.dart' ;
78
8- /// 服务器配置页面 —— 首次启动 / 退出登录后切换服务器时显示。
9- ///
10- /// 触发条件参见 `lib/app/router.dart` 中的 redirect 逻辑:
11- /// 当 `authState.serverUrl` 为空时会被强制路由到这里。
9+ /// 服务器配置页面 —— 首次启动或切换服务器时显示。
1210class ServerConfigScreen extends ConsumerStatefulWidget {
1311 const ServerConfigScreen ({super .key});
1412
1513 @override
16- ConsumerState <ServerConfigScreen > createState () => _ServerConfigScreenState ();
14+ ConsumerState <ServerConfigScreen > createState () =>
15+ _ServerConfigScreenState ();
1716}
1817
1918class _ServerConfigScreenState extends ConsumerState <ServerConfigScreen > {
@@ -26,7 +25,7 @@ class _ServerConfigScreenState extends ConsumerState<ServerConfigScreen> {
2625 @override
2726 void initState () {
2827 super .initState ();
29- _loadHistory ();
28+ _loadSavedServers ();
3029 }
3130
3231 @override
@@ -35,88 +34,117 @@ class _ServerConfigScreenState extends ConsumerState<ServerConfigScreen> {
3534 super .dispose ();
3635 }
3736
38- Future <void > _loadHistory () async {
39- final history = await loadServerHistory ();
37+ Future <void > _loadSavedServers () async {
38+ final results = await Future .wait <dynamic >([
39+ loadServerUrl (),
40+ loadServerHistory (),
41+ ]);
4042 if (! mounted) return ;
43+
44+ final savedUrl = results[0 ] as String ;
45+ final history = results[1 ] as List <ServerRecord >;
4146 setState (() {
4247 _history = history;
43- // 若有历史记录,自动填入最近一次的地址,省得用户再输
44- if (history.isNotEmpty && _urlCtrl.text == 'http://' ) {
48+ if (savedUrl.isNotEmpty) {
49+ _urlCtrl.text = savedUrl;
50+ } else if (history.isNotEmpty && _urlCtrl.text == 'http://' ) {
4551 _urlCtrl.text = history.first.url;
4652 }
4753 });
4854 }
4955
5056 String _normalizeUrl (String raw) {
5157 var url = raw.trim ();
52- // 去尾部斜杠
5358 while (url.endsWith ('/' )) {
5459 url = url.substring (0 , url.length - 1 );
5560 }
5661 return url;
5762 }
5863
5964 String ? _validateUrl (String ? value) {
60- final v = (value ?? '' ).trim ();
61- if (v .isEmpty) return '请输入服务器地址' ;
62- if (! v .startsWith ('http://' ) && ! v .startsWith ('https://' )) {
65+ final input = (value ?? '' ).trim ();
66+ if (input .isEmpty) return '请输入服务器地址' ;
67+ if (! input .startsWith ('http://' ) && ! input .startsWith ('https://' )) {
6368 return '地址必须以 http:// 或 https:// 开头' ;
6469 }
65- final uri = Uri .tryParse (v );
70+ final uri = Uri .tryParse (input );
6671 if (uri == null || uri.host.isEmpty) return '地址格式不正确' ;
6772 return null ;
6873 }
6974
7075 Future <void > _connect () async {
7176 if (! (_formKey.currentState? .validate () ?? false )) return ;
77+
7278 HapticFeedback .lightImpact ();
7379 final url = _normalizeUrl (_urlCtrl.text);
74-
7580 setState (() {
7681 _busy = true ;
7782 _errorMsg = null ;
7883 });
84+
7985 try {
80- final ok = await ref.read (authProvider.notifier).setServerUrl (url);
86+ final connected =
87+ await ref.read (authProvider.notifier).setServerUrl (url);
8188 if (! mounted) return ;
82- if (! ok) {
83- final err = ref.read (authProvider).error;
84- setState (() => _errorMsg = err ?? '无法连接到服务器,请检查地址或网络' );
85- return ;
89+
90+ if (! connected) {
91+ final error = ref.read (authProvider).error;
92+ setState (() {
93+ _errorMsg =
94+ error ?? '服务器暂时不可达,地址已保存,可进入离线缓存' ;
95+ });
8696 }
87- // 成功后 GoRouter 的 redirect 会根据登录状态自动跳到 /login 或 /
88- } catch (e) {
97+ } catch (error) {
8998 if (! mounted) return ;
90- setState (() => _errorMsg = '连接失败:$e ' );
99+ setState (() {
100+ _errorMsg = '连接失败:$error \n 服务器地址已保留' ;
101+ });
91102 } finally {
92- if (mounted) {
93- setState (() => _busy = false );
94- }
103+ if (mounted) setState (() => _busy = false );
95104 }
96105 }
97106
98107 Future <void > _removeHistory (String url) async {
99108 await removeServerRecord (url);
100- await _loadHistory ();
109+ await _loadSavedServers ();
101110 }
102111
103112 @override
104113 Widget build (BuildContext context) {
105114 final theme = Theme .of (context);
115+ final auth = ref.watch (authProvider);
116+
106117 return Scaffold (
118+ appBar: AppBar (
119+ title: const Text ('服务器设置' ),
120+ actions: [
121+ if (auth.isOffline)
122+ TextButton .icon (
123+ onPressed: () => context.go ('/cache' ),
124+ icon: const Icon (Icons .cloud_off_rounded),
125+ label: const Text ('离线书架' ),
126+ ),
127+ ],
128+ ),
107129 body: SafeArea (
108130 child: Center (
109131 child: SingleChildScrollView (
110- padding: const EdgeInsets .symmetric (horizontal: 24 , vertical: 32 ),
132+ padding: const EdgeInsets .symmetric (
133+ horizontal: 24 ,
134+ vertical: 32 ,
135+ ),
111136 child: ConstrainedBox (
112137 constraints: const BoxConstraints (maxWidth: 460 ),
113138 child: Form (
114139 key: _formKey,
115140 child: Column (
116141 crossAxisAlignment: CrossAxisAlignment .stretch,
117142 children: [
118- Icon (Icons .dns_rounded,
119- size: 64 , color: theme.colorScheme.primary),
143+ Icon (
144+ Icons .dns_rounded,
145+ size: 64 ,
146+ color: theme.colorScheme.primary,
147+ ),
120148 const SizedBox (height: 16 ),
121149 Text (
122150 '连接到 Nowen Reader' ,
@@ -127,7 +155,8 @@ class _ServerConfigScreenState extends ConsumerState<ServerConfigScreen> {
127155 ),
128156 const SizedBox (height: 8 ),
129157 Text (
130- '请输入你的服务器地址,例如 http://192.168.1.100:3000' ,
158+ '输入你的服务器地址。地址会先保存在本机,'
159+ '即使当前断网也不会丢失。' ,
131160 textAlign: TextAlign .center,
132161 style: theme.textTheme.bodyMedium? .copyWith (
133162 color: theme.colorScheme.onSurfaceVariant,
@@ -144,8 +173,8 @@ class _ServerConfigScreenState extends ConsumerState<ServerConfigScreen> {
144173 onFieldSubmitted: (_) => _connect (),
145174 decoration: const InputDecoration (
146175 labelText: '服务器地址' ,
147- hintText: 'http://host:port ' ,
148- prefixIcon: Icon (Icons .link ),
176+ hintText: 'http://192.168.1.100:3000 ' ,
177+ prefixIcon: Icon (Icons .link_rounded ),
149178 border: OutlineInputBorder (),
150179 ),
151180 ),
@@ -155,19 +184,23 @@ class _ServerConfigScreenState extends ConsumerState<ServerConfigScreen> {
155184 padding: const EdgeInsets .all (12 ),
156185 decoration: BoxDecoration (
157186 color: theme.colorScheme.errorContainer,
158- borderRadius: BorderRadius .circular (8 ),
187+ borderRadius: BorderRadius .circular (10 ),
159188 ),
160189 child: Row (
190+ crossAxisAlignment: CrossAxisAlignment .start,
161191 children: [
162- Icon (Icons .error_outline,
163- color: theme.colorScheme.onErrorContainer,
164- size: 20 ),
165- const SizedBox (width: 8 ),
192+ Icon (
193+ Icons .cloud_off_rounded,
194+ color:
195+ theme.colorScheme.onErrorContainer,
196+ ),
197+ const SizedBox (width: 10 ),
166198 Expanded (
167199 child: Text (
168200 _errorMsg! ,
169201 style: TextStyle (
170- color: theme.colorScheme.onErrorContainer,
202+ color:
203+ theme.colorScheme.onErrorContainer,
171204 ),
172205 ),
173206 ),
@@ -182,56 +215,70 @@ class _ServerConfigScreenState extends ConsumerState<ServerConfigScreen> {
182215 ? const SizedBox (
183216 width: 18 ,
184217 height: 18 ,
185- child: CircularProgressIndicator (strokeWidth: 2 ),
218+ child: CircularProgressIndicator (
219+ strokeWidth: 2 ,
220+ ),
186221 )
187- : const Icon (Icons .login ),
222+ : const Icon (Icons .login_rounded ),
188223 label: Padding (
189224 padding: const EdgeInsets .symmetric (vertical: 12 ),
190225 child: Text (_busy ? '正在连接…' : '连接服务器' ),
191226 ),
192227 ),
228+ if (auth.isOffline) ...[
229+ const SizedBox (height: 12 ),
230+ OutlinedButton .icon (
231+ onPressed: () => context.go ('/cache' ),
232+ icon: const Icon (Icons .menu_book_rounded),
233+ label: const Padding (
234+ padding: EdgeInsets .symmetric (vertical: 12 ),
235+ child: Text ('服务器不可达,进入离线书架' ),
236+ ),
237+ ),
238+ ],
193239 if (_history.isNotEmpty) ...[
194240 const SizedBox (height: 32 ),
195- Row (
196- children: [
197- Text (
198- '最近使用' ,
199- style: theme.textTheme.titleSmall? .copyWith (
200- color: theme.colorScheme.onSurfaceVariant,
201- ),
202- ),
203- ],
241+ Text (
242+ '最近使用' ,
243+ style: theme.textTheme.titleSmall? .copyWith (
244+ color: theme.colorScheme.onSurfaceVariant,
245+ ),
204246 ),
205247 const SizedBox (height: 8 ),
206- ..._history.map ((rec) => Card (
207- margin : const EdgeInsets . symmetric (vertical : 4 ),
208- child : ListTile (
209- leading : const Icon ( Icons .history),
210- title : Text (
211- rec.url,
212- maxLines : 1 ,
213- overflow : TextOverflow .ellipsis ,
214- ) ,
215- subtitle : rec.username != null
216- ? Text ( '用户:${ rec . nickname ?? rec . username }' )
217- : null ,
218- trailing : IconButton (
219- icon : const Icon ( Icons .close) ,
220- tooltip : '从历史中移除' ,
221- onPressed : _busy
222- ? null
223- : () => _removeHistory (rec.url ),
224- ) ,
225- onTap : _busy
248+ ..._history.map (
249+ (record) => Card (
250+ margin : const EdgeInsets . symmetric (vertical : 4 ),
251+ child : ListTile (
252+ leading : const Icon ( Icons .history_rounded),
253+ title : Text (
254+ record.url ,
255+ maxLines : 1 ,
256+ overflow : TextOverflow .ellipsis ,
257+ ),
258+ subtitle : record.username != null
259+ ? Text (
260+ '用户:'
261+ '${ record . nickname ?? record . username }' ,
262+ )
263+ : null ,
264+ trailing : IconButton (
265+ icon : const Icon ( Icons .close_rounded ),
266+ tooltip : '从历史中移除' ,
267+ onPressed : _busy
226268 ? null
227- : () {
228- setState (() {
229- _urlCtrl.text = rec.url;
230- _errorMsg = null ;
231- });
232- },
269+ : () => _removeHistory (record.url),
233270 ),
234- )),
271+ onTap: _busy
272+ ? null
273+ : () {
274+ setState (() {
275+ _urlCtrl.text = record.url;
276+ _errorMsg = null ;
277+ });
278+ },
279+ ),
280+ ),
281+ ),
235282 ],
236283 ],
237284 ),
0 commit comments